One <PaywallModal> drives 13 triggers — 6 account upsells + 7 Pro upgrades.
How this page works: every card is complete on its own — open the picked card and its drawer holds the full <PaywallModal> Vue + CSS spec, that trigger's parent invocation, and the behavior notes. type drives CTA + price strip; trigger drives image + title + description.
Enjoy early access with a free account.
<PopModal> · ui/components/pop/PopModal.vue<PrimaryButton> · ui/buttons/PrimaryButton.vue<Text> · ui/components/Text.vue<PopModalEyebrow> · catalog<BulletRow> · catalog<PaywallPrice> · catalog<PaywallModal> — new wrapper that composes the atoms above (Vue + scoped CSS below)<script setup>
import { computed } from 'vue'
import PopModal from '@/components/pop/PopModal.vue'
import PopModalEyebrow from '@/components/pop/PopModalEyebrow.vue'
import PrimaryButton from '@/buttons/PrimaryButton.vue'
import Text from '@/components/Text.vue'
import BulletRow from '@/components/BulletRow.vue'
import PaywallPrice from '@/components/PaywallPrice.vue'
const props = defineProps({
show: Boolean,
trigger: { type: String, required: true },
})
const emit = defineEmits(['update:show', 'create-account', 'go-pro'])
const PRO_IMG = 'https://cdn.joypixels.com/emojicopy/assets/marketing/crown.png'
const FAVORITES_IMG = 'https://cdn.joypixels.com/emojicopy/assets/marketing/upsell-favorites.png'
const BOX_DOWN_IMG = 'https://cdn.joypixels.com/emojicopy/assets/marketing/box-down.png'
// Single source of truth — mirrors the app's featureContent config.
const TRIGGERS = {
// -------- Account required (Free account upsell) --------
'account-required' : { type: 'account-required', image: PRO_IMG, title: 'Sign in to keep going', description: 'Enjoy early access with a free account.' },
'use-favorites' : { type: 'account-required', image: FAVORITES_IMG, title: 'Favorites need an account', description: 'Create your free account to start adding items to your Favorites.' },
'hide-action-bar' : { type: 'account-required', image: BOX_DOWN_IMG, title: 'Tweak the toolbar', description: 'Get more features — including hiding the action bar — with a free account.' },
'sort-collections' : { type: 'account-required', image: BOX_DOWN_IMG, title: 'Curate your collections', description: 'Curate from our growing set of in-demand emoji collections.' },
'unfollow-collection' : { type: 'account-required', image: BOX_DOWN_IMG, title: 'Unfollow with an account', description: 'Curate from our growing set of in-demand emoji collections.' },
'remove-items' : { type: 'account-required', image: BOX_DOWN_IMG, title: 'Tidy your recents', description: 'Curate your recent emoji by choosing one or more to remove.' },
// -------- Subscription required (Pro upgrade) -----------
// The bullets-layout variants (sort-smart, create-list, save-combo) supply an `icon` glyph
// + a `bullets` array instead of an image + description. The template branches on which
// shape is present.
'sort-smart' : { type: 'pro-required', icon: '✨', dismiss: 'Keep default', title: 'Smart sort, for you', bullets: [
'Surfaces the emoji you actually use',
'Adapts as your reactions evolve',
'Faster picks, fewer searches' ] },
'create-list' : { type: 'pro-required', icon: '📋', title: 'Build your own lists', bullets: [
'Group emoji by mood or project',
'Reorder, rename, follow lists',
'Unlimited lists with Pro' ] },
'sort-lists' : { type: 'pro-required', image: PRO_IMG, title: 'Add more lists', description: 'Create additional lists and access the perfect emoji more quickly.' },
'save-combo' : { type: 'pro-required', icon: '🧩', title: 'Save combos with Pro', bullets: [
'Keep your best emoji combos forever',
'One click to copy any saved combo',
'Sync across web, Chrome & desktop' ] },
'edit-list' : { type: 'pro-required', image: PRO_IMG, title: 'Rename + edit lists', description: 'Rename this list or create your own lists with a pro account.' },
'clone-collection' : { type: 'pro-required', image: PRO_IMG, title: 'Clone any collection', description: 'Use this as a starting point for your own custom list.' },
'delete-list' : { type: 'pro-required', image: PRO_IMG, title: 'Delete or customize', description: 'Remove this list, or customize it and make it your own.' },
};
const config = computed(() => TRIGGERS[props.trigger])
const isPro = computed(() => config.value.type === 'pro-required')
const offer = { was: '$32', now: '$16 / yr', tag: '50% off · founding' }
</script>
<template>
<PopModal :popped="show" width="380px"
@unpop="emit('update:show', false)">
<div class="pop-modal paywall-modal">
<button class="pop-modal__close"
@click="emit('update:show', false)">×</button>
<PopModalEyebrow>{{ isPro ? 'Pro feature' : 'Free account' }}</PopModalEyebrow>
<Text tag="h3" class="pop-modal__title" :text="config.title" />
<!-- Two layouts share the same `.pop-modal__content` shell:
(a) image + description, (b) icon + checked bullets. -->
<div class="pop-modal__content">
<template v-if="config.bullets">
<div class="pop-modal__icon">{{ config.icon }}</div>
<ul class="pop-modal__bullets">
<BulletRow v-for="b in config.bullets" :key="b"
color="white" :text="b" />
</ul>
</template>
<template v-else>
<img class="pop-modal__image" :src="config.image" alt="" />
<Text type="body/m-reg" class="pop-modal__description"
:text="config.description" />
</template>
</div>
<PaywallPrice v-if="isPro" :was="offer.was" :now="offer.now" :tag="offer.tag" />
<div class="pop-modal__footer">
<PrimaryButton variant="gray" size="md"
:label="config.dismiss ?? 'Not now'"
@click="emit('update:show', false)" />
<PrimaryButton v-if="isPro"
variant="green" size="md" label="Go Pro"
@click="emit('go-pro')" />
<PrimaryButton v-else
variant="green" size="md" label="Sign up"
@click="emit('create-account')" />
</div>
</div>
</PopModal>
</template>
<PaywallModal
v-model:show="modals.paywall"
trigger="account-required"
@create-account="signupFlow"
/>
<style scoped>
.pop-modal {
position: relative;
width: 100%;
max-width: 380px;
padding: 28px 24px 24px;
border-radius: var(--r-lg); /* 16px — matches designer */
background: var(--metal-950); /* #0F1012, NOT a token (Modal token is the backdrop) */
color: var(--white); /* fixed ink on the fixed-dark shell — --c-text-white
flips to metal-950 in dark scheme and would vanish */
box-shadow: var(--shadow-modal);
display: flex;
flex-direction: column;
align-items: center;
gap: 22px;
}
.pop-modal__close {
position: absolute;
top: 16px;
right: 16px;
width: 30px; height: 30px;
display: grid; place-items: center;
border-radius: 50%;
background: color-mix(in srgb, var(--metal-300) 12%, transparent);
box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--metal-300) 14%, transparent);
color: var(--white);
font-size: 1.6rem;
line-height: 1;
border: none;
cursor: pointer;
}
.pop-modal__title {
font-family: var(--ff-dm-sans);
font-weight: 900;
font-size: 2.6rem;
line-height: 1.1;
letter-spacing: -.03em;
text-align: center;
color: var(--white);
margin: 0;
}
.pop-modal__content {
width: 100%;
padding: 24px 20px;
border-radius: var(--r-md);
background: color-mix(in srgb, var(--metal-300) 12%, transparent);
box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--metal-300) 14%, transparent);
display: flex;
flex-direction: column;
align-items: center;
gap: 16px;
}
.pop-modal__icon {
font-size: 4.4rem;
line-height: 1;
}
.pop-modal__image {
width: 96px;
height: 96px;
object-fit: contain;
flex: none;
}
.pop-modal__description {
margin: 0;
text-align: center;
font-family: var(--ff-inter);
font-size: 1.4rem;
line-height: 1.5;
color: color-mix(in srgb, var(--white) 80%, transparent);
}
.pop-modal__bullets {
display: flex;
flex-direction: column;
gap: 8px;
width: 100%;
}
.pop-modal__footer {
display: flex;
gap: 12px;
width: 100%;
}
.pop-modal__footer .button {
flex: 1;
}
</style>
<style scoped>
.pop-modal__eyebrow {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 6px 14px 6px 12px;
border-radius: var(--r-pill);
background: color-mix(in srgb, var(--metal-300) 12%, transparent);
box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--metal-300) 14%, transparent);
font-family: var(--ff-inter);
font-weight: 700;
font-size: 1.3rem;
line-height: 1.4;
color: var(--white);
}
.pop-modal__eyebrow-dot {
width: 10px; height: 10px;
border-radius: 50%;
background: var(--green-600);
flex: none;
}
</style>
<style scoped>
.bullet-row {
display: flex;
align-items: flex-start;
gap: 10px;
font-family: var(--ff-inter);
font-weight: 400;
font-size: 1.4rem;
line-height: 1.5;
color: var(--c-text-secondary);
/* color override propagates to children — matches the app's `c:<color>/txt` from Text.vue */
&.c\:white\/txt {
color: var(--c-text-white);
}
}
/* Legacy check (check-thick_green.png <img> — same asset as Pill's
leading). New rows should use the `.check-circle` SVG instead; this
stays so older mockups keep rendering. */
.bullet-row__check {
flex: none;
width: 16px;
height: 16px;
margin-top: 2px;
}
/* SVG check inside a bullet row — nudge to sit on the text's optical
center like the <img> version does */
.bullet-row .check-circle {
margin-top: 2px;
}
.bullet-row__text {
flex: 1;
}
</style>
<style scoped>
.paywall-price {
width: 100%;
padding: 14px 16px;
border-radius: var(--r-md);
background: color-mix(in srgb, var(--metal-300) 12%, transparent);
box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--metal-300) 14%, transparent);
text-align: center;
line-height: 1.3;
}
.paywall-price__was {
margin-right: 8px;
font-size: 1.2rem;
color: color-mix(in srgb, var(--white) 50%, transparent);
text-decoration: line-through;
}
.paywall-price__now {
font-family: var(--ff-dm-sans);
font-weight: 900;
font-size: 1.9rem;
color: var(--white);
letter-spacing: -.02em;
}
.paywall-price__tag {
display: block;
margin-top: 4px;
font-family: var(--ff-inter);
font-weight: 700;
font-size: 1.05rem;
letter-spacing: .12em;
text-transform: uppercase;
color: var(--green-500);
}
</style>
Create your free account to start adding items to your Favorites.
<PopModal> · ui/components/pop/PopModal.vue<PrimaryButton> · ui/buttons/PrimaryButton.vue<Text> · ui/components/Text.vue<PopModalEyebrow> · catalog<BulletRow> · catalog<PaywallPrice> · catalog<PaywallModal> — new wrapper that composes the atoms above (Vue + scoped CSS below)<script setup>
import { computed } from 'vue'
import PopModal from '@/components/pop/PopModal.vue'
import PopModalEyebrow from '@/components/pop/PopModalEyebrow.vue'
import PrimaryButton from '@/buttons/PrimaryButton.vue'
import Text from '@/components/Text.vue'
import BulletRow from '@/components/BulletRow.vue'
import PaywallPrice from '@/components/PaywallPrice.vue'
const props = defineProps({
show: Boolean,
trigger: { type: String, required: true },
})
const emit = defineEmits(['update:show', 'create-account', 'go-pro'])
const PRO_IMG = 'https://cdn.joypixels.com/emojicopy/assets/marketing/crown.png'
const FAVORITES_IMG = 'https://cdn.joypixels.com/emojicopy/assets/marketing/upsell-favorites.png'
const BOX_DOWN_IMG = 'https://cdn.joypixels.com/emojicopy/assets/marketing/box-down.png'
// Single source of truth — mirrors the app's featureContent config.
const TRIGGERS = {
// -------- Account required (Free account upsell) --------
'account-required' : { type: 'account-required', image: PRO_IMG, title: 'Sign in to keep going', description: 'Enjoy early access with a free account.' },
'use-favorites' : { type: 'account-required', image: FAVORITES_IMG, title: 'Favorites need an account', description: 'Create your free account to start adding items to your Favorites.' },
'hide-action-bar' : { type: 'account-required', image: BOX_DOWN_IMG, title: 'Tweak the toolbar', description: 'Get more features — including hiding the action bar — with a free account.' },
'sort-collections' : { type: 'account-required', image: BOX_DOWN_IMG, title: 'Curate your collections', description: 'Curate from our growing set of in-demand emoji collections.' },
'unfollow-collection' : { type: 'account-required', image: BOX_DOWN_IMG, title: 'Unfollow with an account', description: 'Curate from our growing set of in-demand emoji collections.' },
'remove-items' : { type: 'account-required', image: BOX_DOWN_IMG, title: 'Tidy your recents', description: 'Curate your recent emoji by choosing one or more to remove.' },
// -------- Subscription required (Pro upgrade) -----------
// The bullets-layout variants (sort-smart, create-list, save-combo) supply an `icon` glyph
// + a `bullets` array instead of an image + description. The template branches on which
// shape is present.
'sort-smart' : { type: 'pro-required', icon: '✨', dismiss: 'Keep default', title: 'Smart sort, for you', bullets: [
'Surfaces the emoji you actually use',
'Adapts as your reactions evolve',
'Faster picks, fewer searches' ] },
'create-list' : { type: 'pro-required', icon: '📋', title: 'Build your own lists', bullets: [
'Group emoji by mood or project',
'Reorder, rename, follow lists',
'Unlimited lists with Pro' ] },
'sort-lists' : { type: 'pro-required', image: PRO_IMG, title: 'Add more lists', description: 'Create additional lists and access the perfect emoji more quickly.' },
'save-combo' : { type: 'pro-required', icon: '🧩', title: 'Save combos with Pro', bullets: [
'Keep your best emoji combos forever',
'One click to copy any saved combo',
'Sync across web, Chrome & desktop' ] },
'edit-list' : { type: 'pro-required', image: PRO_IMG, title: 'Rename + edit lists', description: 'Rename this list or create your own lists with a pro account.' },
'clone-collection' : { type: 'pro-required', image: PRO_IMG, title: 'Clone any collection', description: 'Use this as a starting point for your own custom list.' },
'delete-list' : { type: 'pro-required', image: PRO_IMG, title: 'Delete or customize', description: 'Remove this list, or customize it and make it your own.' },
};
const config = computed(() => TRIGGERS[props.trigger])
const isPro = computed(() => config.value.type === 'pro-required')
const offer = { was: '$32', now: '$16 / yr', tag: '50% off · founding' }
</script>
<template>
<PopModal :popped="show" width="380px"
@unpop="emit('update:show', false)">
<div class="pop-modal paywall-modal">
<button class="pop-modal__close"
@click="emit('update:show', false)">×</button>
<PopModalEyebrow>{{ isPro ? 'Pro feature' : 'Free account' }}</PopModalEyebrow>
<Text tag="h3" class="pop-modal__title" :text="config.title" />
<!-- Two layouts share the same `.pop-modal__content` shell:
(a) image + description, (b) icon + checked bullets. -->
<div class="pop-modal__content">
<template v-if="config.bullets">
<div class="pop-modal__icon">{{ config.icon }}</div>
<ul class="pop-modal__bullets">
<BulletRow v-for="b in config.bullets" :key="b"
color="white" :text="b" />
</ul>
</template>
<template v-else>
<img class="pop-modal__image" :src="config.image" alt="" />
<Text type="body/m-reg" class="pop-modal__description"
:text="config.description" />
</template>
</div>
<PaywallPrice v-if="isPro" :was="offer.was" :now="offer.now" :tag="offer.tag" />
<div class="pop-modal__footer">
<PrimaryButton variant="gray" size="md"
:label="config.dismiss ?? 'Not now'"
@click="emit('update:show', false)" />
<PrimaryButton v-if="isPro"
variant="green" size="md" label="Go Pro"
@click="emit('go-pro')" />
<PrimaryButton v-else
variant="green" size="md" label="Sign up"
@click="emit('create-account')" />
</div>
</div>
</PopModal>
</template>
<PaywallModal
v-model:show="modals.paywall"
trigger="use-favorites"
@create-account="signupFlow"
/>
<style scoped>
.pop-modal {
position: relative;
width: 100%;
max-width: 380px;
padding: 28px 24px 24px;
border-radius: var(--r-lg); /* 16px — matches designer */
background: var(--metal-950); /* #0F1012, NOT a token (Modal token is the backdrop) */
color: var(--white); /* fixed ink on the fixed-dark shell — --c-text-white
flips to metal-950 in dark scheme and would vanish */
box-shadow: var(--shadow-modal);
display: flex;
flex-direction: column;
align-items: center;
gap: 22px;
}
.pop-modal__close {
position: absolute;
top: 16px;
right: 16px;
width: 30px; height: 30px;
display: grid; place-items: center;
border-radius: 50%;
background: color-mix(in srgb, var(--metal-300) 12%, transparent);
box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--metal-300) 14%, transparent);
color: var(--white);
font-size: 1.6rem;
line-height: 1;
border: none;
cursor: pointer;
}
.pop-modal__title {
font-family: var(--ff-dm-sans);
font-weight: 900;
font-size: 2.6rem;
line-height: 1.1;
letter-spacing: -.03em;
text-align: center;
color: var(--white);
margin: 0;
}
.pop-modal__content {
width: 100%;
padding: 24px 20px;
border-radius: var(--r-md);
background: color-mix(in srgb, var(--metal-300) 12%, transparent);
box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--metal-300) 14%, transparent);
display: flex;
flex-direction: column;
align-items: center;
gap: 16px;
}
.pop-modal__icon {
font-size: 4.4rem;
line-height: 1;
}
.pop-modal__image {
width: 96px;
height: 96px;
object-fit: contain;
flex: none;
}
.pop-modal__description {
margin: 0;
text-align: center;
font-family: var(--ff-inter);
font-size: 1.4rem;
line-height: 1.5;
color: color-mix(in srgb, var(--white) 80%, transparent);
}
.pop-modal__bullets {
display: flex;
flex-direction: column;
gap: 8px;
width: 100%;
}
.pop-modal__footer {
display: flex;
gap: 12px;
width: 100%;
}
.pop-modal__footer .button {
flex: 1;
}
</style>
<style scoped>
.pop-modal__eyebrow {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 6px 14px 6px 12px;
border-radius: var(--r-pill);
background: color-mix(in srgb, var(--metal-300) 12%, transparent);
box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--metal-300) 14%, transparent);
font-family: var(--ff-inter);
font-weight: 700;
font-size: 1.3rem;
line-height: 1.4;
color: var(--white);
}
.pop-modal__eyebrow-dot {
width: 10px; height: 10px;
border-radius: 50%;
background: var(--green-600);
flex: none;
}
</style>
<style scoped>
.bullet-row {
display: flex;
align-items: flex-start;
gap: 10px;
font-family: var(--ff-inter);
font-weight: 400;
font-size: 1.4rem;
line-height: 1.5;
color: var(--c-text-secondary);
/* color override propagates to children — matches the app's `c:<color>/txt` from Text.vue */
&.c\:white\/txt {
color: var(--c-text-white);
}
}
/* Legacy check (check-thick_green.png <img> — same asset as Pill's
leading). New rows should use the `.check-circle` SVG instead; this
stays so older mockups keep rendering. */
.bullet-row__check {
flex: none;
width: 16px;
height: 16px;
margin-top: 2px;
}
/* SVG check inside a bullet row — nudge to sit on the text's optical
center like the <img> version does */
.bullet-row .check-circle {
margin-top: 2px;
}
.bullet-row__text {
flex: 1;
}
</style>
<style scoped>
.paywall-price {
width: 100%;
padding: 14px 16px;
border-radius: var(--r-md);
background: color-mix(in srgb, var(--metal-300) 12%, transparent);
box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--metal-300) 14%, transparent);
text-align: center;
line-height: 1.3;
}
.paywall-price__was {
margin-right: 8px;
font-size: 1.2rem;
color: color-mix(in srgb, var(--white) 50%, transparent);
text-decoration: line-through;
}
.paywall-price__now {
font-family: var(--ff-dm-sans);
font-weight: 900;
font-size: 1.9rem;
color: var(--white);
letter-spacing: -.02em;
}
.paywall-price__tag {
display: block;
margin-top: 4px;
font-family: var(--ff-inter);
font-weight: 700;
font-size: 1.05rem;
letter-spacing: .12em;
text-transform: uppercase;
color: var(--green-500);
}
</style>
Get more features — including hiding the action bar — with a free account.
<PopModal> · ui/components/pop/PopModal.vue<PrimaryButton> · ui/buttons/PrimaryButton.vue<Text> · ui/components/Text.vue<PopModalEyebrow> · catalog<BulletRow> · catalog<PaywallPrice> · catalog<PaywallModal> — new wrapper that composes the atoms above (Vue + scoped CSS below)<script setup>
import { computed } from 'vue'
import PopModal from '@/components/pop/PopModal.vue'
import PopModalEyebrow from '@/components/pop/PopModalEyebrow.vue'
import PrimaryButton from '@/buttons/PrimaryButton.vue'
import Text from '@/components/Text.vue'
import BulletRow from '@/components/BulletRow.vue'
import PaywallPrice from '@/components/PaywallPrice.vue'
const props = defineProps({
show: Boolean,
trigger: { type: String, required: true },
})
const emit = defineEmits(['update:show', 'create-account', 'go-pro'])
const PRO_IMG = 'https://cdn.joypixels.com/emojicopy/assets/marketing/crown.png'
const FAVORITES_IMG = 'https://cdn.joypixels.com/emojicopy/assets/marketing/upsell-favorites.png'
const BOX_DOWN_IMG = 'https://cdn.joypixels.com/emojicopy/assets/marketing/box-down.png'
// Single source of truth — mirrors the app's featureContent config.
const TRIGGERS = {
// -------- Account required (Free account upsell) --------
'account-required' : { type: 'account-required', image: PRO_IMG, title: 'Sign in to keep going', description: 'Enjoy early access with a free account.' },
'use-favorites' : { type: 'account-required', image: FAVORITES_IMG, title: 'Favorites need an account', description: 'Create your free account to start adding items to your Favorites.' },
'hide-action-bar' : { type: 'account-required', image: BOX_DOWN_IMG, title: 'Tweak the toolbar', description: 'Get more features — including hiding the action bar — with a free account.' },
'sort-collections' : { type: 'account-required', image: BOX_DOWN_IMG, title: 'Curate your collections', description: 'Curate from our growing set of in-demand emoji collections.' },
'unfollow-collection' : { type: 'account-required', image: BOX_DOWN_IMG, title: 'Unfollow with an account', description: 'Curate from our growing set of in-demand emoji collections.' },
'remove-items' : { type: 'account-required', image: BOX_DOWN_IMG, title: 'Tidy your recents', description: 'Curate your recent emoji by choosing one or more to remove.' },
// -------- Subscription required (Pro upgrade) -----------
// The bullets-layout variants (sort-smart, create-list, save-combo) supply an `icon` glyph
// + a `bullets` array instead of an image + description. The template branches on which
// shape is present.
'sort-smart' : { type: 'pro-required', icon: '✨', dismiss: 'Keep default', title: 'Smart sort, for you', bullets: [
'Surfaces the emoji you actually use',
'Adapts as your reactions evolve',
'Faster picks, fewer searches' ] },
'create-list' : { type: 'pro-required', icon: '📋', title: 'Build your own lists', bullets: [
'Group emoji by mood or project',
'Reorder, rename, follow lists',
'Unlimited lists with Pro' ] },
'sort-lists' : { type: 'pro-required', image: PRO_IMG, title: 'Add more lists', description: 'Create additional lists and access the perfect emoji more quickly.' },
'save-combo' : { type: 'pro-required', icon: '🧩', title: 'Save combos with Pro', bullets: [
'Keep your best emoji combos forever',
'One click to copy any saved combo',
'Sync across web, Chrome & desktop' ] },
'edit-list' : { type: 'pro-required', image: PRO_IMG, title: 'Rename + edit lists', description: 'Rename this list or create your own lists with a pro account.' },
'clone-collection' : { type: 'pro-required', image: PRO_IMG, title: 'Clone any collection', description: 'Use this as a starting point for your own custom list.' },
'delete-list' : { type: 'pro-required', image: PRO_IMG, title: 'Delete or customize', description: 'Remove this list, or customize it and make it your own.' },
};
const config = computed(() => TRIGGERS[props.trigger])
const isPro = computed(() => config.value.type === 'pro-required')
const offer = { was: '$32', now: '$16 / yr', tag: '50% off · founding' }
</script>
<template>
<PopModal :popped="show" width="380px"
@unpop="emit('update:show', false)">
<div class="pop-modal paywall-modal">
<button class="pop-modal__close"
@click="emit('update:show', false)">×</button>
<PopModalEyebrow>{{ isPro ? 'Pro feature' : 'Free account' }}</PopModalEyebrow>
<Text tag="h3" class="pop-modal__title" :text="config.title" />
<!-- Two layouts share the same `.pop-modal__content` shell:
(a) image + description, (b) icon + checked bullets. -->
<div class="pop-modal__content">
<template v-if="config.bullets">
<div class="pop-modal__icon">{{ config.icon }}</div>
<ul class="pop-modal__bullets">
<BulletRow v-for="b in config.bullets" :key="b"
color="white" :text="b" />
</ul>
</template>
<template v-else>
<img class="pop-modal__image" :src="config.image" alt="" />
<Text type="body/m-reg" class="pop-modal__description"
:text="config.description" />
</template>
</div>
<PaywallPrice v-if="isPro" :was="offer.was" :now="offer.now" :tag="offer.tag" />
<div class="pop-modal__footer">
<PrimaryButton variant="gray" size="md"
:label="config.dismiss ?? 'Not now'"
@click="emit('update:show', false)" />
<PrimaryButton v-if="isPro"
variant="green" size="md" label="Go Pro"
@click="emit('go-pro')" />
<PrimaryButton v-else
variant="green" size="md" label="Sign up"
@click="emit('create-account')" />
</div>
</div>
</PopModal>
</template>
<PaywallModal
v-model:show="modals.paywall"
trigger="hide-action-bar"
@create-account="signupFlow"
/>
<style scoped>
.pop-modal {
position: relative;
width: 100%;
max-width: 380px;
padding: 28px 24px 24px;
border-radius: var(--r-lg); /* 16px — matches designer */
background: var(--metal-950); /* #0F1012, NOT a token (Modal token is the backdrop) */
color: var(--white); /* fixed ink on the fixed-dark shell — --c-text-white
flips to metal-950 in dark scheme and would vanish */
box-shadow: var(--shadow-modal);
display: flex;
flex-direction: column;
align-items: center;
gap: 22px;
}
.pop-modal__close {
position: absolute;
top: 16px;
right: 16px;
width: 30px; height: 30px;
display: grid; place-items: center;
border-radius: 50%;
background: color-mix(in srgb, var(--metal-300) 12%, transparent);
box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--metal-300) 14%, transparent);
color: var(--white);
font-size: 1.6rem;
line-height: 1;
border: none;
cursor: pointer;
}
.pop-modal__title {
font-family: var(--ff-dm-sans);
font-weight: 900;
font-size: 2.6rem;
line-height: 1.1;
letter-spacing: -.03em;
text-align: center;
color: var(--white);
margin: 0;
}
.pop-modal__content {
width: 100%;
padding: 24px 20px;
border-radius: var(--r-md);
background: color-mix(in srgb, var(--metal-300) 12%, transparent);
box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--metal-300) 14%, transparent);
display: flex;
flex-direction: column;
align-items: center;
gap: 16px;
}
.pop-modal__icon {
font-size: 4.4rem;
line-height: 1;
}
.pop-modal__image {
width: 96px;
height: 96px;
object-fit: contain;
flex: none;
}
.pop-modal__description {
margin: 0;
text-align: center;
font-family: var(--ff-inter);
font-size: 1.4rem;
line-height: 1.5;
color: color-mix(in srgb, var(--white) 80%, transparent);
}
.pop-modal__bullets {
display: flex;
flex-direction: column;
gap: 8px;
width: 100%;
}
.pop-modal__footer {
display: flex;
gap: 12px;
width: 100%;
}
.pop-modal__footer .button {
flex: 1;
}
</style>
<style scoped>
.pop-modal__eyebrow {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 6px 14px 6px 12px;
border-radius: var(--r-pill);
background: color-mix(in srgb, var(--metal-300) 12%, transparent);
box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--metal-300) 14%, transparent);
font-family: var(--ff-inter);
font-weight: 700;
font-size: 1.3rem;
line-height: 1.4;
color: var(--white);
}
.pop-modal__eyebrow-dot {
width: 10px; height: 10px;
border-radius: 50%;
background: var(--green-600);
flex: none;
}
</style>
<style scoped>
.bullet-row {
display: flex;
align-items: flex-start;
gap: 10px;
font-family: var(--ff-inter);
font-weight: 400;
font-size: 1.4rem;
line-height: 1.5;
color: var(--c-text-secondary);
/* color override propagates to children — matches the app's `c:<color>/txt` from Text.vue */
&.c\:white\/txt {
color: var(--c-text-white);
}
}
/* Legacy check (check-thick_green.png <img> — same asset as Pill's
leading). New rows should use the `.check-circle` SVG instead; this
stays so older mockups keep rendering. */
.bullet-row__check {
flex: none;
width: 16px;
height: 16px;
margin-top: 2px;
}
/* SVG check inside a bullet row — nudge to sit on the text's optical
center like the <img> version does */
.bullet-row .check-circle {
margin-top: 2px;
}
.bullet-row__text {
flex: 1;
}
</style>
<style scoped>
.paywall-price {
width: 100%;
padding: 14px 16px;
border-radius: var(--r-md);
background: color-mix(in srgb, var(--metal-300) 12%, transparent);
box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--metal-300) 14%, transparent);
text-align: center;
line-height: 1.3;
}
.paywall-price__was {
margin-right: 8px;
font-size: 1.2rem;
color: color-mix(in srgb, var(--white) 50%, transparent);
text-decoration: line-through;
}
.paywall-price__now {
font-family: var(--ff-dm-sans);
font-weight: 900;
font-size: 1.9rem;
color: var(--white);
letter-spacing: -.02em;
}
.paywall-price__tag {
display: block;
margin-top: 4px;
font-family: var(--ff-inter);
font-weight: 700;
font-size: 1.05rem;
letter-spacing: .12em;
text-transform: uppercase;
color: var(--green-500);
}
</style>
Curate from our growing set of in-demand emoji collections.
<PopModal> · ui/components/pop/PopModal.vue<PrimaryButton> · ui/buttons/PrimaryButton.vue<Text> · ui/components/Text.vue<PopModalEyebrow> · catalog<BulletRow> · catalog<PaywallPrice> · catalog<PaywallModal> — new wrapper that composes the atoms above (Vue + scoped CSS below)<script setup>
import { computed } from 'vue'
import PopModal from '@/components/pop/PopModal.vue'
import PopModalEyebrow from '@/components/pop/PopModalEyebrow.vue'
import PrimaryButton from '@/buttons/PrimaryButton.vue'
import Text from '@/components/Text.vue'
import BulletRow from '@/components/BulletRow.vue'
import PaywallPrice from '@/components/PaywallPrice.vue'
const props = defineProps({
show: Boolean,
trigger: { type: String, required: true },
})
const emit = defineEmits(['update:show', 'create-account', 'go-pro'])
const PRO_IMG = 'https://cdn.joypixels.com/emojicopy/assets/marketing/crown.png'
const FAVORITES_IMG = 'https://cdn.joypixels.com/emojicopy/assets/marketing/upsell-favorites.png'
const BOX_DOWN_IMG = 'https://cdn.joypixels.com/emojicopy/assets/marketing/box-down.png'
// Single source of truth — mirrors the app's featureContent config.
const TRIGGERS = {
// -------- Account required (Free account upsell) --------
'account-required' : { type: 'account-required', image: PRO_IMG, title: 'Sign in to keep going', description: 'Enjoy early access with a free account.' },
'use-favorites' : { type: 'account-required', image: FAVORITES_IMG, title: 'Favorites need an account', description: 'Create your free account to start adding items to your Favorites.' },
'hide-action-bar' : { type: 'account-required', image: BOX_DOWN_IMG, title: 'Tweak the toolbar', description: 'Get more features — including hiding the action bar — with a free account.' },
'sort-collections' : { type: 'account-required', image: BOX_DOWN_IMG, title: 'Curate your collections', description: 'Curate from our growing set of in-demand emoji collections.' },
'unfollow-collection' : { type: 'account-required', image: BOX_DOWN_IMG, title: 'Unfollow with an account', description: 'Curate from our growing set of in-demand emoji collections.' },
'remove-items' : { type: 'account-required', image: BOX_DOWN_IMG, title: 'Tidy your recents', description: 'Curate your recent emoji by choosing one or more to remove.' },
// -------- Subscription required (Pro upgrade) -----------
// The bullets-layout variants (sort-smart, create-list, save-combo) supply an `icon` glyph
// + a `bullets` array instead of an image + description. The template branches on which
// shape is present.
'sort-smart' : { type: 'pro-required', icon: '✨', dismiss: 'Keep default', title: 'Smart sort, for you', bullets: [
'Surfaces the emoji you actually use',
'Adapts as your reactions evolve',
'Faster picks, fewer searches' ] },
'create-list' : { type: 'pro-required', icon: '📋', title: 'Build your own lists', bullets: [
'Group emoji by mood or project',
'Reorder, rename, follow lists',
'Unlimited lists with Pro' ] },
'sort-lists' : { type: 'pro-required', image: PRO_IMG, title: 'Add more lists', description: 'Create additional lists and access the perfect emoji more quickly.' },
'save-combo' : { type: 'pro-required', icon: '🧩', title: 'Save combos with Pro', bullets: [
'Keep your best emoji combos forever',
'One click to copy any saved combo',
'Sync across web, Chrome & desktop' ] },
'edit-list' : { type: 'pro-required', image: PRO_IMG, title: 'Rename + edit lists', description: 'Rename this list or create your own lists with a pro account.' },
'clone-collection' : { type: 'pro-required', image: PRO_IMG, title: 'Clone any collection', description: 'Use this as a starting point for your own custom list.' },
'delete-list' : { type: 'pro-required', image: PRO_IMG, title: 'Delete or customize', description: 'Remove this list, or customize it and make it your own.' },
};
const config = computed(() => TRIGGERS[props.trigger])
const isPro = computed(() => config.value.type === 'pro-required')
const offer = { was: '$32', now: '$16 / yr', tag: '50% off · founding' }
</script>
<template>
<PopModal :popped="show" width="380px"
@unpop="emit('update:show', false)">
<div class="pop-modal paywall-modal">
<button class="pop-modal__close"
@click="emit('update:show', false)">×</button>
<PopModalEyebrow>{{ isPro ? 'Pro feature' : 'Free account' }}</PopModalEyebrow>
<Text tag="h3" class="pop-modal__title" :text="config.title" />
<!-- Two layouts share the same `.pop-modal__content` shell:
(a) image + description, (b) icon + checked bullets. -->
<div class="pop-modal__content">
<template v-if="config.bullets">
<div class="pop-modal__icon">{{ config.icon }}</div>
<ul class="pop-modal__bullets">
<BulletRow v-for="b in config.bullets" :key="b"
color="white" :text="b" />
</ul>
</template>
<template v-else>
<img class="pop-modal__image" :src="config.image" alt="" />
<Text type="body/m-reg" class="pop-modal__description"
:text="config.description" />
</template>
</div>
<PaywallPrice v-if="isPro" :was="offer.was" :now="offer.now" :tag="offer.tag" />
<div class="pop-modal__footer">
<PrimaryButton variant="gray" size="md"
:label="config.dismiss ?? 'Not now'"
@click="emit('update:show', false)" />
<PrimaryButton v-if="isPro"
variant="green" size="md" label="Go Pro"
@click="emit('go-pro')" />
<PrimaryButton v-else
variant="green" size="md" label="Sign up"
@click="emit('create-account')" />
</div>
</div>
</PopModal>
</template>
<PaywallModal
v-model:show="modals.paywall"
trigger="sort-collections"
@create-account="signupFlow"
/>
<style scoped>
.pop-modal {
position: relative;
width: 100%;
max-width: 380px;
padding: 28px 24px 24px;
border-radius: var(--r-lg); /* 16px — matches designer */
background: var(--metal-950); /* #0F1012, NOT a token (Modal token is the backdrop) */
color: var(--white); /* fixed ink on the fixed-dark shell — --c-text-white
flips to metal-950 in dark scheme and would vanish */
box-shadow: var(--shadow-modal);
display: flex;
flex-direction: column;
align-items: center;
gap: 22px;
}
.pop-modal__close {
position: absolute;
top: 16px;
right: 16px;
width: 30px; height: 30px;
display: grid; place-items: center;
border-radius: 50%;
background: color-mix(in srgb, var(--metal-300) 12%, transparent);
box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--metal-300) 14%, transparent);
color: var(--white);
font-size: 1.6rem;
line-height: 1;
border: none;
cursor: pointer;
}
.pop-modal__title {
font-family: var(--ff-dm-sans);
font-weight: 900;
font-size: 2.6rem;
line-height: 1.1;
letter-spacing: -.03em;
text-align: center;
color: var(--white);
margin: 0;
}
.pop-modal__content {
width: 100%;
padding: 24px 20px;
border-radius: var(--r-md);
background: color-mix(in srgb, var(--metal-300) 12%, transparent);
box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--metal-300) 14%, transparent);
display: flex;
flex-direction: column;
align-items: center;
gap: 16px;
}
.pop-modal__icon {
font-size: 4.4rem;
line-height: 1;
}
.pop-modal__image {
width: 96px;
height: 96px;
object-fit: contain;
flex: none;
}
.pop-modal__description {
margin: 0;
text-align: center;
font-family: var(--ff-inter);
font-size: 1.4rem;
line-height: 1.5;
color: color-mix(in srgb, var(--white) 80%, transparent);
}
.pop-modal__bullets {
display: flex;
flex-direction: column;
gap: 8px;
width: 100%;
}
.pop-modal__footer {
display: flex;
gap: 12px;
width: 100%;
}
.pop-modal__footer .button {
flex: 1;
}
</style>
<style scoped>
.pop-modal__eyebrow {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 6px 14px 6px 12px;
border-radius: var(--r-pill);
background: color-mix(in srgb, var(--metal-300) 12%, transparent);
box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--metal-300) 14%, transparent);
font-family: var(--ff-inter);
font-weight: 700;
font-size: 1.3rem;
line-height: 1.4;
color: var(--white);
}
.pop-modal__eyebrow-dot {
width: 10px; height: 10px;
border-radius: 50%;
background: var(--green-600);
flex: none;
}
</style>
<style scoped>
.bullet-row {
display: flex;
align-items: flex-start;
gap: 10px;
font-family: var(--ff-inter);
font-weight: 400;
font-size: 1.4rem;
line-height: 1.5;
color: var(--c-text-secondary);
/* color override propagates to children — matches the app's `c:<color>/txt` from Text.vue */
&.c\:white\/txt {
color: var(--c-text-white);
}
}
/* Legacy check (check-thick_green.png <img> — same asset as Pill's
leading). New rows should use the `.check-circle` SVG instead; this
stays so older mockups keep rendering. */
.bullet-row__check {
flex: none;
width: 16px;
height: 16px;
margin-top: 2px;
}
/* SVG check inside a bullet row — nudge to sit on the text's optical
center like the <img> version does */
.bullet-row .check-circle {
margin-top: 2px;
}
.bullet-row__text {
flex: 1;
}
</style>
<style scoped>
.paywall-price {
width: 100%;
padding: 14px 16px;
border-radius: var(--r-md);
background: color-mix(in srgb, var(--metal-300) 12%, transparent);
box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--metal-300) 14%, transparent);
text-align: center;
line-height: 1.3;
}
.paywall-price__was {
margin-right: 8px;
font-size: 1.2rem;
color: color-mix(in srgb, var(--white) 50%, transparent);
text-decoration: line-through;
}
.paywall-price__now {
font-family: var(--ff-dm-sans);
font-weight: 900;
font-size: 1.9rem;
color: var(--white);
letter-spacing: -.02em;
}
.paywall-price__tag {
display: block;
margin-top: 4px;
font-family: var(--ff-inter);
font-weight: 700;
font-size: 1.05rem;
letter-spacing: .12em;
text-transform: uppercase;
color: var(--green-500);
}
</style>
Curate from our growing set of in-demand emoji collections.
<PopModal> · ui/components/pop/PopModal.vue<PrimaryButton> · ui/buttons/PrimaryButton.vue<Text> · ui/components/Text.vue<PopModalEyebrow> · catalog<BulletRow> · catalog<PaywallPrice> · catalog<PaywallModal> — new wrapper that composes the atoms above (Vue + scoped CSS below)<script setup>
import { computed } from 'vue'
import PopModal from '@/components/pop/PopModal.vue'
import PopModalEyebrow from '@/components/pop/PopModalEyebrow.vue'
import PrimaryButton from '@/buttons/PrimaryButton.vue'
import Text from '@/components/Text.vue'
import BulletRow from '@/components/BulletRow.vue'
import PaywallPrice from '@/components/PaywallPrice.vue'
const props = defineProps({
show: Boolean,
trigger: { type: String, required: true },
})
const emit = defineEmits(['update:show', 'create-account', 'go-pro'])
const PRO_IMG = 'https://cdn.joypixels.com/emojicopy/assets/marketing/crown.png'
const FAVORITES_IMG = 'https://cdn.joypixels.com/emojicopy/assets/marketing/upsell-favorites.png'
const BOX_DOWN_IMG = 'https://cdn.joypixels.com/emojicopy/assets/marketing/box-down.png'
// Single source of truth — mirrors the app's featureContent config.
const TRIGGERS = {
// -------- Account required (Free account upsell) --------
'account-required' : { type: 'account-required', image: PRO_IMG, title: 'Sign in to keep going', description: 'Enjoy early access with a free account.' },
'use-favorites' : { type: 'account-required', image: FAVORITES_IMG, title: 'Favorites need an account', description: 'Create your free account to start adding items to your Favorites.' },
'hide-action-bar' : { type: 'account-required', image: BOX_DOWN_IMG, title: 'Tweak the toolbar', description: 'Get more features — including hiding the action bar — with a free account.' },
'sort-collections' : { type: 'account-required', image: BOX_DOWN_IMG, title: 'Curate your collections', description: 'Curate from our growing set of in-demand emoji collections.' },
'unfollow-collection' : { type: 'account-required', image: BOX_DOWN_IMG, title: 'Unfollow with an account', description: 'Curate from our growing set of in-demand emoji collections.' },
'remove-items' : { type: 'account-required', image: BOX_DOWN_IMG, title: 'Tidy your recents', description: 'Curate your recent emoji by choosing one or more to remove.' },
// -------- Subscription required (Pro upgrade) -----------
// The bullets-layout variants (sort-smart, create-list, save-combo) supply an `icon` glyph
// + a `bullets` array instead of an image + description. The template branches on which
// shape is present.
'sort-smart' : { type: 'pro-required', icon: '✨', dismiss: 'Keep default', title: 'Smart sort, for you', bullets: [
'Surfaces the emoji you actually use',
'Adapts as your reactions evolve',
'Faster picks, fewer searches' ] },
'create-list' : { type: 'pro-required', icon: '📋', title: 'Build your own lists', bullets: [
'Group emoji by mood or project',
'Reorder, rename, follow lists',
'Unlimited lists with Pro' ] },
'sort-lists' : { type: 'pro-required', image: PRO_IMG, title: 'Add more lists', description: 'Create additional lists and access the perfect emoji more quickly.' },
'save-combo' : { type: 'pro-required', icon: '🧩', title: 'Save combos with Pro', bullets: [
'Keep your best emoji combos forever',
'One click to copy any saved combo',
'Sync across web, Chrome & desktop' ] },
'edit-list' : { type: 'pro-required', image: PRO_IMG, title: 'Rename + edit lists', description: 'Rename this list or create your own lists with a pro account.' },
'clone-collection' : { type: 'pro-required', image: PRO_IMG, title: 'Clone any collection', description: 'Use this as a starting point for your own custom list.' },
'delete-list' : { type: 'pro-required', image: PRO_IMG, title: 'Delete or customize', description: 'Remove this list, or customize it and make it your own.' },
};
const config = computed(() => TRIGGERS[props.trigger])
const isPro = computed(() => config.value.type === 'pro-required')
const offer = { was: '$32', now: '$16 / yr', tag: '50% off · founding' }
</script>
<template>
<PopModal :popped="show" width="380px"
@unpop="emit('update:show', false)">
<div class="pop-modal paywall-modal">
<button class="pop-modal__close"
@click="emit('update:show', false)">×</button>
<PopModalEyebrow>{{ isPro ? 'Pro feature' : 'Free account' }}</PopModalEyebrow>
<Text tag="h3" class="pop-modal__title" :text="config.title" />
<!-- Two layouts share the same `.pop-modal__content` shell:
(a) image + description, (b) icon + checked bullets. -->
<div class="pop-modal__content">
<template v-if="config.bullets">
<div class="pop-modal__icon">{{ config.icon }}</div>
<ul class="pop-modal__bullets">
<BulletRow v-for="b in config.bullets" :key="b"
color="white" :text="b" />
</ul>
</template>
<template v-else>
<img class="pop-modal__image" :src="config.image" alt="" />
<Text type="body/m-reg" class="pop-modal__description"
:text="config.description" />
</template>
</div>
<PaywallPrice v-if="isPro" :was="offer.was" :now="offer.now" :tag="offer.tag" />
<div class="pop-modal__footer">
<PrimaryButton variant="gray" size="md"
:label="config.dismiss ?? 'Not now'"
@click="emit('update:show', false)" />
<PrimaryButton v-if="isPro"
variant="green" size="md" label="Go Pro"
@click="emit('go-pro')" />
<PrimaryButton v-else
variant="green" size="md" label="Sign up"
@click="emit('create-account')" />
</div>
</div>
</PopModal>
</template>
<PaywallModal
v-model:show="modals.paywall"
trigger="unfollow-collection"
@create-account="signupFlow"
/>
<!-- NOTE: sort-collections and unfollow-collection share identical
description copy in the app config (featureContent). Different
trigger keys → different telemetry events, same modal content. -->
<style scoped>
.pop-modal {
position: relative;
width: 100%;
max-width: 380px;
padding: 28px 24px 24px;
border-radius: var(--r-lg); /* 16px — matches designer */
background: var(--metal-950); /* #0F1012, NOT a token (Modal token is the backdrop) */
color: var(--white); /* fixed ink on the fixed-dark shell — --c-text-white
flips to metal-950 in dark scheme and would vanish */
box-shadow: var(--shadow-modal);
display: flex;
flex-direction: column;
align-items: center;
gap: 22px;
}
.pop-modal__close {
position: absolute;
top: 16px;
right: 16px;
width: 30px; height: 30px;
display: grid; place-items: center;
border-radius: 50%;
background: color-mix(in srgb, var(--metal-300) 12%, transparent);
box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--metal-300) 14%, transparent);
color: var(--white);
font-size: 1.6rem;
line-height: 1;
border: none;
cursor: pointer;
}
.pop-modal__title {
font-family: var(--ff-dm-sans);
font-weight: 900;
font-size: 2.6rem;
line-height: 1.1;
letter-spacing: -.03em;
text-align: center;
color: var(--white);
margin: 0;
}
.pop-modal__content {
width: 100%;
padding: 24px 20px;
border-radius: var(--r-md);
background: color-mix(in srgb, var(--metal-300) 12%, transparent);
box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--metal-300) 14%, transparent);
display: flex;
flex-direction: column;
align-items: center;
gap: 16px;
}
.pop-modal__icon {
font-size: 4.4rem;
line-height: 1;
}
.pop-modal__image {
width: 96px;
height: 96px;
object-fit: contain;
flex: none;
}
.pop-modal__description {
margin: 0;
text-align: center;
font-family: var(--ff-inter);
font-size: 1.4rem;
line-height: 1.5;
color: color-mix(in srgb, var(--white) 80%, transparent);
}
.pop-modal__bullets {
display: flex;
flex-direction: column;
gap: 8px;
width: 100%;
}
.pop-modal__footer {
display: flex;
gap: 12px;
width: 100%;
}
.pop-modal__footer .button {
flex: 1;
}
</style>
<style scoped>
.pop-modal__eyebrow {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 6px 14px 6px 12px;
border-radius: var(--r-pill);
background: color-mix(in srgb, var(--metal-300) 12%, transparent);
box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--metal-300) 14%, transparent);
font-family: var(--ff-inter);
font-weight: 700;
font-size: 1.3rem;
line-height: 1.4;
color: var(--white);
}
.pop-modal__eyebrow-dot {
width: 10px; height: 10px;
border-radius: 50%;
background: var(--green-600);
flex: none;
}
</style>
<style scoped>
.bullet-row {
display: flex;
align-items: flex-start;
gap: 10px;
font-family: var(--ff-inter);
font-weight: 400;
font-size: 1.4rem;
line-height: 1.5;
color: var(--c-text-secondary);
/* color override propagates to children — matches the app's `c:<color>/txt` from Text.vue */
&.c\:white\/txt {
color: var(--c-text-white);
}
}
/* Legacy check (check-thick_green.png <img> — same asset as Pill's
leading). New rows should use the `.check-circle` SVG instead; this
stays so older mockups keep rendering. */
.bullet-row__check {
flex: none;
width: 16px;
height: 16px;
margin-top: 2px;
}
/* SVG check inside a bullet row — nudge to sit on the text's optical
center like the <img> version does */
.bullet-row .check-circle {
margin-top: 2px;
}
.bullet-row__text {
flex: 1;
}
</style>
<style scoped>
.paywall-price {
width: 100%;
padding: 14px 16px;
border-radius: var(--r-md);
background: color-mix(in srgb, var(--metal-300) 12%, transparent);
box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--metal-300) 14%, transparent);
text-align: center;
line-height: 1.3;
}
.paywall-price__was {
margin-right: 8px;
font-size: 1.2rem;
color: color-mix(in srgb, var(--white) 50%, transparent);
text-decoration: line-through;
}
.paywall-price__now {
font-family: var(--ff-dm-sans);
font-weight: 900;
font-size: 1.9rem;
color: var(--white);
letter-spacing: -.02em;
}
.paywall-price__tag {
display: block;
margin-top: 4px;
font-family: var(--ff-inter);
font-weight: 700;
font-size: 1.05rem;
letter-spacing: .12em;
text-transform: uppercase;
color: var(--green-500);
}
</style>
Curate your recent emoji by choosing one or more to remove.
<PopModal> · ui/components/pop/PopModal.vue<PrimaryButton> · ui/buttons/PrimaryButton.vue<Text> · ui/components/Text.vue<PopModalEyebrow> · catalog<BulletRow> · catalog<PaywallPrice> · catalog<PaywallModal> — new wrapper that composes the atoms above (Vue + scoped CSS below)<script setup>
import { computed } from 'vue'
import PopModal from '@/components/pop/PopModal.vue'
import PopModalEyebrow from '@/components/pop/PopModalEyebrow.vue'
import PrimaryButton from '@/buttons/PrimaryButton.vue'
import Text from '@/components/Text.vue'
import BulletRow from '@/components/BulletRow.vue'
import PaywallPrice from '@/components/PaywallPrice.vue'
const props = defineProps({
show: Boolean,
trigger: { type: String, required: true },
})
const emit = defineEmits(['update:show', 'create-account', 'go-pro'])
const PRO_IMG = 'https://cdn.joypixels.com/emojicopy/assets/marketing/crown.png'
const FAVORITES_IMG = 'https://cdn.joypixels.com/emojicopy/assets/marketing/upsell-favorites.png'
const BOX_DOWN_IMG = 'https://cdn.joypixels.com/emojicopy/assets/marketing/box-down.png'
// Single source of truth — mirrors the app's featureContent config.
const TRIGGERS = {
// -------- Account required (Free account upsell) --------
'account-required' : { type: 'account-required', image: PRO_IMG, title: 'Sign in to keep going', description: 'Enjoy early access with a free account.' },
'use-favorites' : { type: 'account-required', image: FAVORITES_IMG, title: 'Favorites need an account', description: 'Create your free account to start adding items to your Favorites.' },
'hide-action-bar' : { type: 'account-required', image: BOX_DOWN_IMG, title: 'Tweak the toolbar', description: 'Get more features — including hiding the action bar — with a free account.' },
'sort-collections' : { type: 'account-required', image: BOX_DOWN_IMG, title: 'Curate your collections', description: 'Curate from our growing set of in-demand emoji collections.' },
'unfollow-collection' : { type: 'account-required', image: BOX_DOWN_IMG, title: 'Unfollow with an account', description: 'Curate from our growing set of in-demand emoji collections.' },
'remove-items' : { type: 'account-required', image: BOX_DOWN_IMG, title: 'Tidy your recents', description: 'Curate your recent emoji by choosing one or more to remove.' },
// -------- Subscription required (Pro upgrade) -----------
// The bullets-layout variants (sort-smart, create-list, save-combo) supply an `icon` glyph
// + a `bullets` array instead of an image + description. The template branches on which
// shape is present.
'sort-smart' : { type: 'pro-required', icon: '✨', dismiss: 'Keep default', title: 'Smart sort, for you', bullets: [
'Surfaces the emoji you actually use',
'Adapts as your reactions evolve',
'Faster picks, fewer searches' ] },
'create-list' : { type: 'pro-required', icon: '📋', title: 'Build your own lists', bullets: [
'Group emoji by mood or project',
'Reorder, rename, follow lists',
'Unlimited lists with Pro' ] },
'sort-lists' : { type: 'pro-required', image: PRO_IMG, title: 'Add more lists', description: 'Create additional lists and access the perfect emoji more quickly.' },
'save-combo' : { type: 'pro-required', icon: '🧩', title: 'Save combos with Pro', bullets: [
'Keep your best emoji combos forever',
'One click to copy any saved combo',
'Sync across web, Chrome & desktop' ] },
'edit-list' : { type: 'pro-required', image: PRO_IMG, title: 'Rename + edit lists', description: 'Rename this list or create your own lists with a pro account.' },
'clone-collection' : { type: 'pro-required', image: PRO_IMG, title: 'Clone any collection', description: 'Use this as a starting point for your own custom list.' },
'delete-list' : { type: 'pro-required', image: PRO_IMG, title: 'Delete or customize', description: 'Remove this list, or customize it and make it your own.' },
};
const config = computed(() => TRIGGERS[props.trigger])
const isPro = computed(() => config.value.type === 'pro-required')
const offer = { was: '$32', now: '$16 / yr', tag: '50% off · founding' }
</script>
<template>
<PopModal :popped="show" width="380px"
@unpop="emit('update:show', false)">
<div class="pop-modal paywall-modal">
<button class="pop-modal__close"
@click="emit('update:show', false)">×</button>
<PopModalEyebrow>{{ isPro ? 'Pro feature' : 'Free account' }}</PopModalEyebrow>
<Text tag="h3" class="pop-modal__title" :text="config.title" />
<!-- Two layouts share the same `.pop-modal__content` shell:
(a) image + description, (b) icon + checked bullets. -->
<div class="pop-modal__content">
<template v-if="config.bullets">
<div class="pop-modal__icon">{{ config.icon }}</div>
<ul class="pop-modal__bullets">
<BulletRow v-for="b in config.bullets" :key="b"
color="white" :text="b" />
</ul>
</template>
<template v-else>
<img class="pop-modal__image" :src="config.image" alt="" />
<Text type="body/m-reg" class="pop-modal__description"
:text="config.description" />
</template>
</div>
<PaywallPrice v-if="isPro" :was="offer.was" :now="offer.now" :tag="offer.tag" />
<div class="pop-modal__footer">
<PrimaryButton variant="gray" size="md"
:label="config.dismiss ?? 'Not now'"
@click="emit('update:show', false)" />
<PrimaryButton v-if="isPro"
variant="green" size="md" label="Go Pro"
@click="emit('go-pro')" />
<PrimaryButton v-else
variant="green" size="md" label="Sign up"
@click="emit('create-account')" />
</div>
</div>
</PopModal>
</template>
<PaywallModal
v-model:show="modals.paywall"
trigger="remove-items"
@create-account="signupFlow"
/>
<style scoped>
.pop-modal {
position: relative;
width: 100%;
max-width: 380px;
padding: 28px 24px 24px;
border-radius: var(--r-lg); /* 16px — matches designer */
background: var(--metal-950); /* #0F1012, NOT a token (Modal token is the backdrop) */
color: var(--white); /* fixed ink on the fixed-dark shell — --c-text-white
flips to metal-950 in dark scheme and would vanish */
box-shadow: var(--shadow-modal);
display: flex;
flex-direction: column;
align-items: center;
gap: 22px;
}
.pop-modal__close {
position: absolute;
top: 16px;
right: 16px;
width: 30px; height: 30px;
display: grid; place-items: center;
border-radius: 50%;
background: color-mix(in srgb, var(--metal-300) 12%, transparent);
box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--metal-300) 14%, transparent);
color: var(--white);
font-size: 1.6rem;
line-height: 1;
border: none;
cursor: pointer;
}
.pop-modal__title {
font-family: var(--ff-dm-sans);
font-weight: 900;
font-size: 2.6rem;
line-height: 1.1;
letter-spacing: -.03em;
text-align: center;
color: var(--white);
margin: 0;
}
.pop-modal__content {
width: 100%;
padding: 24px 20px;
border-radius: var(--r-md);
background: color-mix(in srgb, var(--metal-300) 12%, transparent);
box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--metal-300) 14%, transparent);
display: flex;
flex-direction: column;
align-items: center;
gap: 16px;
}
.pop-modal__icon {
font-size: 4.4rem;
line-height: 1;
}
.pop-modal__image {
width: 96px;
height: 96px;
object-fit: contain;
flex: none;
}
.pop-modal__description {
margin: 0;
text-align: center;
font-family: var(--ff-inter);
font-size: 1.4rem;
line-height: 1.5;
color: color-mix(in srgb, var(--white) 80%, transparent);
}
.pop-modal__bullets {
display: flex;
flex-direction: column;
gap: 8px;
width: 100%;
}
.pop-modal__footer {
display: flex;
gap: 12px;
width: 100%;
}
.pop-modal__footer .button {
flex: 1;
}
</style>
<style scoped>
.pop-modal__eyebrow {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 6px 14px 6px 12px;
border-radius: var(--r-pill);
background: color-mix(in srgb, var(--metal-300) 12%, transparent);
box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--metal-300) 14%, transparent);
font-family: var(--ff-inter);
font-weight: 700;
font-size: 1.3rem;
line-height: 1.4;
color: var(--white);
}
.pop-modal__eyebrow-dot {
width: 10px; height: 10px;
border-radius: 50%;
background: var(--green-600);
flex: none;
}
</style>
<style scoped>
.bullet-row {
display: flex;
align-items: flex-start;
gap: 10px;
font-family: var(--ff-inter);
font-weight: 400;
font-size: 1.4rem;
line-height: 1.5;
color: var(--c-text-secondary);
/* color override propagates to children — matches the app's `c:<color>/txt` from Text.vue */
&.c\:white\/txt {
color: var(--c-text-white);
}
}
/* Legacy check (check-thick_green.png <img> — same asset as Pill's
leading). New rows should use the `.check-circle` SVG instead; this
stays so older mockups keep rendering. */
.bullet-row__check {
flex: none;
width: 16px;
height: 16px;
margin-top: 2px;
}
/* SVG check inside a bullet row — nudge to sit on the text's optical
center like the <img> version does */
.bullet-row .check-circle {
margin-top: 2px;
}
.bullet-row__text {
flex: 1;
}
</style>
<style scoped>
.paywall-price {
width: 100%;
padding: 14px 16px;
border-radius: var(--r-md);
background: color-mix(in srgb, var(--metal-300) 12%, transparent);
box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--metal-300) 14%, transparent);
text-align: center;
line-height: 1.3;
}
.paywall-price__was {
margin-right: 8px;
font-size: 1.2rem;
color: color-mix(in srgb, var(--white) 50%, transparent);
text-decoration: line-through;
}
.paywall-price__now {
font-family: var(--ff-dm-sans);
font-weight: 900;
font-size: 1.9rem;
color: var(--white);
letter-spacing: -.02em;
}
.paywall-price__tag {
display: block;
margin-top: 4px;
font-family: var(--ff-inter);
font-weight: 700;
font-size: 1.05rem;
letter-spacing: .12em;
text-transform: uppercase;
color: var(--green-500);
}
</style>
<PopModal> · ui/components/pop/PopModal.vue<PrimaryButton> · ui/buttons/PrimaryButton.vue<Text> · ui/components/Text.vue<PopModalEyebrow> · catalog<BulletRow> · catalog<PaywallPrice> · catalog<PaywallModal> — new wrapper that composes the atoms above (Vue + scoped CSS below)<script setup>
import { computed } from 'vue'
import PopModal from '@/components/pop/PopModal.vue'
import PopModalEyebrow from '@/components/pop/PopModalEyebrow.vue'
import PrimaryButton from '@/buttons/PrimaryButton.vue'
import Text from '@/components/Text.vue'
import BulletRow from '@/components/BulletRow.vue'
import PaywallPrice from '@/components/PaywallPrice.vue'
const props = defineProps({
show: Boolean,
trigger: { type: String, required: true },
})
const emit = defineEmits(['update:show', 'create-account', 'go-pro'])
const PRO_IMG = 'https://cdn.joypixels.com/emojicopy/assets/marketing/crown.png'
const FAVORITES_IMG = 'https://cdn.joypixels.com/emojicopy/assets/marketing/upsell-favorites.png'
const BOX_DOWN_IMG = 'https://cdn.joypixels.com/emojicopy/assets/marketing/box-down.png'
// Single source of truth — mirrors the app's featureContent config.
const TRIGGERS = {
// -------- Account required (Free account upsell) --------
'account-required' : { type: 'account-required', image: PRO_IMG, title: 'Sign in to keep going', description: 'Enjoy early access with a free account.' },
'use-favorites' : { type: 'account-required', image: FAVORITES_IMG, title: 'Favorites need an account', description: 'Create your free account to start adding items to your Favorites.' },
'hide-action-bar' : { type: 'account-required', image: BOX_DOWN_IMG, title: 'Tweak the toolbar', description: 'Get more features — including hiding the action bar — with a free account.' },
'sort-collections' : { type: 'account-required', image: BOX_DOWN_IMG, title: 'Curate your collections', description: 'Curate from our growing set of in-demand emoji collections.' },
'unfollow-collection' : { type: 'account-required', image: BOX_DOWN_IMG, title: 'Unfollow with an account', description: 'Curate from our growing set of in-demand emoji collections.' },
'remove-items' : { type: 'account-required', image: BOX_DOWN_IMG, title: 'Tidy your recents', description: 'Curate your recent emoji by choosing one or more to remove.' },
// -------- Subscription required (Pro upgrade) -----------
// The bullets-layout variants (sort-smart, create-list, save-combo) supply an `icon` glyph
// + a `bullets` array instead of an image + description. The template branches on which
// shape is present.
'sort-smart' : { type: 'pro-required', icon: '✨', dismiss: 'Keep default', title: 'Smart sort, for you', bullets: [
'Surfaces the emoji you actually use',
'Adapts as your reactions evolve',
'Faster picks, fewer searches' ] },
'create-list' : { type: 'pro-required', icon: '📋', title: 'Build your own lists', bullets: [
'Group emoji by mood or project',
'Reorder, rename, follow lists',
'Unlimited lists with Pro' ] },
'sort-lists' : { type: 'pro-required', image: PRO_IMG, title: 'Add more lists', description: 'Create additional lists and access the perfect emoji more quickly.' },
'save-combo' : { type: 'pro-required', icon: '🧩', title: 'Save combos with Pro', bullets: [
'Keep your best emoji combos forever',
'One click to copy any saved combo',
'Sync across web, Chrome & desktop' ] },
'edit-list' : { type: 'pro-required', image: PRO_IMG, title: 'Rename + edit lists', description: 'Rename this list or create your own lists with a pro account.' },
'clone-collection' : { type: 'pro-required', image: PRO_IMG, title: 'Clone any collection', description: 'Use this as a starting point for your own custom list.' },
'delete-list' : { type: 'pro-required', image: PRO_IMG, title: 'Delete or customize', description: 'Remove this list, or customize it and make it your own.' },
};
const config = computed(() => TRIGGERS[props.trigger])
const isPro = computed(() => config.value.type === 'pro-required')
const offer = { was: '$32', now: '$16 / yr', tag: '50% off · founding' }
</script>
<template>
<PopModal :popped="show" width="380px"
@unpop="emit('update:show', false)">
<div class="pop-modal paywall-modal">
<button class="pop-modal__close"
@click="emit('update:show', false)">×</button>
<PopModalEyebrow>{{ isPro ? 'Pro feature' : 'Free account' }}</PopModalEyebrow>
<Text tag="h3" class="pop-modal__title" :text="config.title" />
<!-- Two layouts share the same `.pop-modal__content` shell:
(a) image + description, (b) icon + checked bullets. -->
<div class="pop-modal__content">
<template v-if="config.bullets">
<div class="pop-modal__icon">{{ config.icon }}</div>
<ul class="pop-modal__bullets">
<BulletRow v-for="b in config.bullets" :key="b"
color="white" :text="b" />
</ul>
</template>
<template v-else>
<img class="pop-modal__image" :src="config.image" alt="" />
<Text type="body/m-reg" class="pop-modal__description"
:text="config.description" />
</template>
</div>
<PaywallPrice v-if="isPro" :was="offer.was" :now="offer.now" :tag="offer.tag" />
<div class="pop-modal__footer">
<PrimaryButton variant="gray" size="md"
:label="config.dismiss ?? 'Not now'"
@click="emit('update:show', false)" />
<PrimaryButton v-if="isPro"
variant="green" size="md" label="Go Pro"
@click="emit('go-pro')" />
<PrimaryButton v-else
variant="green" size="md" label="Sign up"
@click="emit('create-account')" />
</div>
</div>
</PopModal>
</template>
<PaywallModal
v-model:show="modals.paywall"
trigger="sort-smart"
@go-pro="checkout"
/>
<style scoped>
.pop-modal {
position: relative;
width: 100%;
max-width: 380px;
padding: 28px 24px 24px;
border-radius: var(--r-lg); /* 16px — matches designer */
background: var(--metal-950); /* #0F1012, NOT a token (Modal token is the backdrop) */
color: var(--white); /* fixed ink on the fixed-dark shell — --c-text-white
flips to metal-950 in dark scheme and would vanish */
box-shadow: var(--shadow-modal);
display: flex;
flex-direction: column;
align-items: center;
gap: 22px;
}
.pop-modal__close {
position: absolute;
top: 16px;
right: 16px;
width: 30px; height: 30px;
display: grid; place-items: center;
border-radius: 50%;
background: color-mix(in srgb, var(--metal-300) 12%, transparent);
box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--metal-300) 14%, transparent);
color: var(--white);
font-size: 1.6rem;
line-height: 1;
border: none;
cursor: pointer;
}
.pop-modal__title {
font-family: var(--ff-dm-sans);
font-weight: 900;
font-size: 2.6rem;
line-height: 1.1;
letter-spacing: -.03em;
text-align: center;
color: var(--white);
margin: 0;
}
.pop-modal__content {
width: 100%;
padding: 24px 20px;
border-radius: var(--r-md);
background: color-mix(in srgb, var(--metal-300) 12%, transparent);
box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--metal-300) 14%, transparent);
display: flex;
flex-direction: column;
align-items: center;
gap: 16px;
}
.pop-modal__icon {
font-size: 4.4rem;
line-height: 1;
}
.pop-modal__image {
width: 96px;
height: 96px;
object-fit: contain;
flex: none;
}
.pop-modal__description {
margin: 0;
text-align: center;
font-family: var(--ff-inter);
font-size: 1.4rem;
line-height: 1.5;
color: color-mix(in srgb, var(--white) 80%, transparent);
}
.pop-modal__bullets {
display: flex;
flex-direction: column;
gap: 8px;
width: 100%;
}
.pop-modal__footer {
display: flex;
gap: 12px;
width: 100%;
}
.pop-modal__footer .button {
flex: 1;
}
</style>
<style scoped>
.pop-modal__eyebrow {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 6px 14px 6px 12px;
border-radius: var(--r-pill);
background: color-mix(in srgb, var(--metal-300) 12%, transparent);
box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--metal-300) 14%, transparent);
font-family: var(--ff-inter);
font-weight: 700;
font-size: 1.3rem;
line-height: 1.4;
color: var(--white);
}
.pop-modal__eyebrow-dot {
width: 10px; height: 10px;
border-radius: 50%;
background: var(--green-600);
flex: none;
}
</style>
<style scoped>
.bullet-row {
display: flex;
align-items: flex-start;
gap: 10px;
font-family: var(--ff-inter);
font-weight: 400;
font-size: 1.4rem;
line-height: 1.5;
color: var(--c-text-secondary);
/* color override propagates to children — matches the app's `c:<color>/txt` from Text.vue */
&.c\:white\/txt {
color: var(--c-text-white);
}
}
/* Legacy check (check-thick_green.png <img> — same asset as Pill's
leading). New rows should use the `.check-circle` SVG instead; this
stays so older mockups keep rendering. */
.bullet-row__check {
flex: none;
width: 16px;
height: 16px;
margin-top: 2px;
}
/* SVG check inside a bullet row — nudge to sit on the text's optical
center like the <img> version does */
.bullet-row .check-circle {
margin-top: 2px;
}
.bullet-row__text {
flex: 1;
}
</style>
<style scoped>
.paywall-price {
width: 100%;
padding: 14px 16px;
border-radius: var(--r-md);
background: color-mix(in srgb, var(--metal-300) 12%, transparent);
box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--metal-300) 14%, transparent);
text-align: center;
line-height: 1.3;
}
.paywall-price__was {
margin-right: 8px;
font-size: 1.2rem;
color: color-mix(in srgb, var(--white) 50%, transparent);
text-decoration: line-through;
}
.paywall-price__now {
font-family: var(--ff-dm-sans);
font-weight: 900;
font-size: 1.9rem;
color: var(--white);
letter-spacing: -.02em;
}
.paywall-price__tag {
display: block;
margin-top: 4px;
font-family: var(--ff-inter);
font-weight: 700;
font-size: 1.05rem;
letter-spacing: .12em;
text-transform: uppercase;
color: var(--green-500);
}
</style>
<PopModal> · ui/components/pop/PopModal.vue<PrimaryButton> · ui/buttons/PrimaryButton.vue<Text> · ui/components/Text.vue<PopModalEyebrow> · catalog<BulletRow> · catalog<PaywallPrice> · catalog<PaywallModal> — new wrapper that composes the atoms above (Vue + scoped CSS below)<script setup>
import { computed } from 'vue'
import PopModal from '@/components/pop/PopModal.vue'
import PopModalEyebrow from '@/components/pop/PopModalEyebrow.vue'
import PrimaryButton from '@/buttons/PrimaryButton.vue'
import Text from '@/components/Text.vue'
import BulletRow from '@/components/BulletRow.vue'
import PaywallPrice from '@/components/PaywallPrice.vue'
const props = defineProps({
show: Boolean,
trigger: { type: String, required: true },
})
const emit = defineEmits(['update:show', 'create-account', 'go-pro'])
const PRO_IMG = 'https://cdn.joypixels.com/emojicopy/assets/marketing/crown.png'
const FAVORITES_IMG = 'https://cdn.joypixels.com/emojicopy/assets/marketing/upsell-favorites.png'
const BOX_DOWN_IMG = 'https://cdn.joypixels.com/emojicopy/assets/marketing/box-down.png'
// Single source of truth — mirrors the app's featureContent config.
const TRIGGERS = {
// -------- Account required (Free account upsell) --------
'account-required' : { type: 'account-required', image: PRO_IMG, title: 'Sign in to keep going', description: 'Enjoy early access with a free account.' },
'use-favorites' : { type: 'account-required', image: FAVORITES_IMG, title: 'Favorites need an account', description: 'Create your free account to start adding items to your Favorites.' },
'hide-action-bar' : { type: 'account-required', image: BOX_DOWN_IMG, title: 'Tweak the toolbar', description: 'Get more features — including hiding the action bar — with a free account.' },
'sort-collections' : { type: 'account-required', image: BOX_DOWN_IMG, title: 'Curate your collections', description: 'Curate from our growing set of in-demand emoji collections.' },
'unfollow-collection' : { type: 'account-required', image: BOX_DOWN_IMG, title: 'Unfollow with an account', description: 'Curate from our growing set of in-demand emoji collections.' },
'remove-items' : { type: 'account-required', image: BOX_DOWN_IMG, title: 'Tidy your recents', description: 'Curate your recent emoji by choosing one or more to remove.' },
// -------- Subscription required (Pro upgrade) -----------
// The bullets-layout variants (sort-smart, create-list, save-combo) supply an `icon` glyph
// + a `bullets` array instead of an image + description. The template branches on which
// shape is present.
'sort-smart' : { type: 'pro-required', icon: '✨', dismiss: 'Keep default', title: 'Smart sort, for you', bullets: [
'Surfaces the emoji you actually use',
'Adapts as your reactions evolve',
'Faster picks, fewer searches' ] },
'create-list' : { type: 'pro-required', icon: '📋', title: 'Build your own lists', bullets: [
'Group emoji by mood or project',
'Reorder, rename, follow lists',
'Unlimited lists with Pro' ] },
'sort-lists' : { type: 'pro-required', image: PRO_IMG, title: 'Add more lists', description: 'Create additional lists and access the perfect emoji more quickly.' },
'save-combo' : { type: 'pro-required', icon: '🧩', title: 'Save combos with Pro', bullets: [
'Keep your best emoji combos forever',
'One click to copy any saved combo',
'Sync across web, Chrome & desktop' ] },
'edit-list' : { type: 'pro-required', image: PRO_IMG, title: 'Rename + edit lists', description: 'Rename this list or create your own lists with a pro account.' },
'clone-collection' : { type: 'pro-required', image: PRO_IMG, title: 'Clone any collection', description: 'Use this as a starting point for your own custom list.' },
'delete-list' : { type: 'pro-required', image: PRO_IMG, title: 'Delete or customize', description: 'Remove this list, or customize it and make it your own.' },
};
const config = computed(() => TRIGGERS[props.trigger])
const isPro = computed(() => config.value.type === 'pro-required')
const offer = { was: '$32', now: '$16 / yr', tag: '50% off · founding' }
</script>
<template>
<PopModal :popped="show" width="380px"
@unpop="emit('update:show', false)">
<div class="pop-modal paywall-modal">
<button class="pop-modal__close"
@click="emit('update:show', false)">×</button>
<PopModalEyebrow>{{ isPro ? 'Pro feature' : 'Free account' }}</PopModalEyebrow>
<Text tag="h3" class="pop-modal__title" :text="config.title" />
<!-- Two layouts share the same `.pop-modal__content` shell:
(a) image + description, (b) icon + checked bullets. -->
<div class="pop-modal__content">
<template v-if="config.bullets">
<div class="pop-modal__icon">{{ config.icon }}</div>
<ul class="pop-modal__bullets">
<BulletRow v-for="b in config.bullets" :key="b"
color="white" :text="b" />
</ul>
</template>
<template v-else>
<img class="pop-modal__image" :src="config.image" alt="" />
<Text type="body/m-reg" class="pop-modal__description"
:text="config.description" />
</template>
</div>
<PaywallPrice v-if="isPro" :was="offer.was" :now="offer.now" :tag="offer.tag" />
<div class="pop-modal__footer">
<PrimaryButton variant="gray" size="md"
:label="config.dismiss ?? 'Not now'"
@click="emit('update:show', false)" />
<PrimaryButton v-if="isPro"
variant="green" size="md" label="Go Pro"
@click="emit('go-pro')" />
<PrimaryButton v-else
variant="green" size="md" label="Sign up"
@click="emit('create-account')" />
</div>
</div>
</PopModal>
</template>
<PaywallModal
v-model:show="modals.paywall"
trigger="create-list"
@go-pro="checkout"
/>
<style scoped>
.pop-modal {
position: relative;
width: 100%;
max-width: 380px;
padding: 28px 24px 24px;
border-radius: var(--r-lg); /* 16px — matches designer */
background: var(--metal-950); /* #0F1012, NOT a token (Modal token is the backdrop) */
color: var(--white); /* fixed ink on the fixed-dark shell — --c-text-white
flips to metal-950 in dark scheme and would vanish */
box-shadow: var(--shadow-modal);
display: flex;
flex-direction: column;
align-items: center;
gap: 22px;
}
.pop-modal__close {
position: absolute;
top: 16px;
right: 16px;
width: 30px; height: 30px;
display: grid; place-items: center;
border-radius: 50%;
background: color-mix(in srgb, var(--metal-300) 12%, transparent);
box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--metal-300) 14%, transparent);
color: var(--white);
font-size: 1.6rem;
line-height: 1;
border: none;
cursor: pointer;
}
.pop-modal__title {
font-family: var(--ff-dm-sans);
font-weight: 900;
font-size: 2.6rem;
line-height: 1.1;
letter-spacing: -.03em;
text-align: center;
color: var(--white);
margin: 0;
}
.pop-modal__content {
width: 100%;
padding: 24px 20px;
border-radius: var(--r-md);
background: color-mix(in srgb, var(--metal-300) 12%, transparent);
box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--metal-300) 14%, transparent);
display: flex;
flex-direction: column;
align-items: center;
gap: 16px;
}
.pop-modal__icon {
font-size: 4.4rem;
line-height: 1;
}
.pop-modal__image {
width: 96px;
height: 96px;
object-fit: contain;
flex: none;
}
.pop-modal__description {
margin: 0;
text-align: center;
font-family: var(--ff-inter);
font-size: 1.4rem;
line-height: 1.5;
color: color-mix(in srgb, var(--white) 80%, transparent);
}
.pop-modal__bullets {
display: flex;
flex-direction: column;
gap: 8px;
width: 100%;
}
.pop-modal__footer {
display: flex;
gap: 12px;
width: 100%;
}
.pop-modal__footer .button {
flex: 1;
}
</style>
<style scoped>
.pop-modal__eyebrow {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 6px 14px 6px 12px;
border-radius: var(--r-pill);
background: color-mix(in srgb, var(--metal-300) 12%, transparent);
box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--metal-300) 14%, transparent);
font-family: var(--ff-inter);
font-weight: 700;
font-size: 1.3rem;
line-height: 1.4;
color: var(--white);
}
.pop-modal__eyebrow-dot {
width: 10px; height: 10px;
border-radius: 50%;
background: var(--green-600);
flex: none;
}
</style>
<style scoped>
.bullet-row {
display: flex;
align-items: flex-start;
gap: 10px;
font-family: var(--ff-inter);
font-weight: 400;
font-size: 1.4rem;
line-height: 1.5;
color: var(--c-text-secondary);
/* color override propagates to children — matches the app's `c:<color>/txt` from Text.vue */
&.c\:white\/txt {
color: var(--c-text-white);
}
}
/* Legacy check (check-thick_green.png <img> — same asset as Pill's
leading). New rows should use the `.check-circle` SVG instead; this
stays so older mockups keep rendering. */
.bullet-row__check {
flex: none;
width: 16px;
height: 16px;
margin-top: 2px;
}
/* SVG check inside a bullet row — nudge to sit on the text's optical
center like the <img> version does */
.bullet-row .check-circle {
margin-top: 2px;
}
.bullet-row__text {
flex: 1;
}
</style>
<style scoped>
.paywall-price {
width: 100%;
padding: 14px 16px;
border-radius: var(--r-md);
background: color-mix(in srgb, var(--metal-300) 12%, transparent);
box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--metal-300) 14%, transparent);
text-align: center;
line-height: 1.3;
}
.paywall-price__was {
margin-right: 8px;
font-size: 1.2rem;
color: color-mix(in srgb, var(--white) 50%, transparent);
text-decoration: line-through;
}
.paywall-price__now {
font-family: var(--ff-dm-sans);
font-weight: 900;
font-size: 1.9rem;
color: var(--white);
letter-spacing: -.02em;
}
.paywall-price__tag {
display: block;
margin-top: 4px;
font-family: var(--ff-inter);
font-weight: 700;
font-size: 1.05rem;
letter-spacing: .12em;
text-transform: uppercase;
color: var(--green-500);
}
</style>
Create additional lists and access the perfect emoji more quickly.
<PopModal> · ui/components/pop/PopModal.vue<PrimaryButton> · ui/buttons/PrimaryButton.vue<Text> · ui/components/Text.vue<PopModalEyebrow> · catalog<BulletRow> · catalog<PaywallPrice> · catalog<PaywallModal> — new wrapper that composes the atoms above (Vue + scoped CSS below)<script setup>
import { computed } from 'vue'
import PopModal from '@/components/pop/PopModal.vue'
import PopModalEyebrow from '@/components/pop/PopModalEyebrow.vue'
import PrimaryButton from '@/buttons/PrimaryButton.vue'
import Text from '@/components/Text.vue'
import BulletRow from '@/components/BulletRow.vue'
import PaywallPrice from '@/components/PaywallPrice.vue'
const props = defineProps({
show: Boolean,
trigger: { type: String, required: true },
})
const emit = defineEmits(['update:show', 'create-account', 'go-pro'])
const PRO_IMG = 'https://cdn.joypixels.com/emojicopy/assets/marketing/crown.png'
const FAVORITES_IMG = 'https://cdn.joypixels.com/emojicopy/assets/marketing/upsell-favorites.png'
const BOX_DOWN_IMG = 'https://cdn.joypixels.com/emojicopy/assets/marketing/box-down.png'
// Single source of truth — mirrors the app's featureContent config.
const TRIGGERS = {
// -------- Account required (Free account upsell) --------
'account-required' : { type: 'account-required', image: PRO_IMG, title: 'Sign in to keep going', description: 'Enjoy early access with a free account.' },
'use-favorites' : { type: 'account-required', image: FAVORITES_IMG, title: 'Favorites need an account', description: 'Create your free account to start adding items to your Favorites.' },
'hide-action-bar' : { type: 'account-required', image: BOX_DOWN_IMG, title: 'Tweak the toolbar', description: 'Get more features — including hiding the action bar — with a free account.' },
'sort-collections' : { type: 'account-required', image: BOX_DOWN_IMG, title: 'Curate your collections', description: 'Curate from our growing set of in-demand emoji collections.' },
'unfollow-collection' : { type: 'account-required', image: BOX_DOWN_IMG, title: 'Unfollow with an account', description: 'Curate from our growing set of in-demand emoji collections.' },
'remove-items' : { type: 'account-required', image: BOX_DOWN_IMG, title: 'Tidy your recents', description: 'Curate your recent emoji by choosing one or more to remove.' },
// -------- Subscription required (Pro upgrade) -----------
// The bullets-layout variants (sort-smart, create-list, save-combo) supply an `icon` glyph
// + a `bullets` array instead of an image + description. The template branches on which
// shape is present.
'sort-smart' : { type: 'pro-required', icon: '✨', dismiss: 'Keep default', title: 'Smart sort, for you', bullets: [
'Surfaces the emoji you actually use',
'Adapts as your reactions evolve',
'Faster picks, fewer searches' ] },
'create-list' : { type: 'pro-required', icon: '📋', title: 'Build your own lists', bullets: [
'Group emoji by mood or project',
'Reorder, rename, follow lists',
'Unlimited lists with Pro' ] },
'sort-lists' : { type: 'pro-required', image: PRO_IMG, title: 'Add more lists', description: 'Create additional lists and access the perfect emoji more quickly.' },
'save-combo' : { type: 'pro-required', icon: '🧩', title: 'Save combos with Pro', bullets: [
'Keep your best emoji combos forever',
'One click to copy any saved combo',
'Sync across web, Chrome & desktop' ] },
'edit-list' : { type: 'pro-required', image: PRO_IMG, title: 'Rename + edit lists', description: 'Rename this list or create your own lists with a pro account.' },
'clone-collection' : { type: 'pro-required', image: PRO_IMG, title: 'Clone any collection', description: 'Use this as a starting point for your own custom list.' },
'delete-list' : { type: 'pro-required', image: PRO_IMG, title: 'Delete or customize', description: 'Remove this list, or customize it and make it your own.' },
};
const config = computed(() => TRIGGERS[props.trigger])
const isPro = computed(() => config.value.type === 'pro-required')
const offer = { was: '$32', now: '$16 / yr', tag: '50% off · founding' }
</script>
<template>
<PopModal :popped="show" width="380px"
@unpop="emit('update:show', false)">
<div class="pop-modal paywall-modal">
<button class="pop-modal__close"
@click="emit('update:show', false)">×</button>
<PopModalEyebrow>{{ isPro ? 'Pro feature' : 'Free account' }}</PopModalEyebrow>
<Text tag="h3" class="pop-modal__title" :text="config.title" />
<!-- Two layouts share the same `.pop-modal__content` shell:
(a) image + description, (b) icon + checked bullets. -->
<div class="pop-modal__content">
<template v-if="config.bullets">
<div class="pop-modal__icon">{{ config.icon }}</div>
<ul class="pop-modal__bullets">
<BulletRow v-for="b in config.bullets" :key="b"
color="white" :text="b" />
</ul>
</template>
<template v-else>
<img class="pop-modal__image" :src="config.image" alt="" />
<Text type="body/m-reg" class="pop-modal__description"
:text="config.description" />
</template>
</div>
<PaywallPrice v-if="isPro" :was="offer.was" :now="offer.now" :tag="offer.tag" />
<div class="pop-modal__footer">
<PrimaryButton variant="gray" size="md"
:label="config.dismiss ?? 'Not now'"
@click="emit('update:show', false)" />
<PrimaryButton v-if="isPro"
variant="green" size="md" label="Go Pro"
@click="emit('go-pro')" />
<PrimaryButton v-else
variant="green" size="md" label="Sign up"
@click="emit('create-account')" />
</div>
</div>
</PopModal>
</template>
<PaywallModal
v-model:show="modals.paywall"
trigger="sort-lists"
@go-pro="checkout"
/>
<style scoped>
.pop-modal {
position: relative;
width: 100%;
max-width: 380px;
padding: 28px 24px 24px;
border-radius: var(--r-lg); /* 16px — matches designer */
background: var(--metal-950); /* #0F1012, NOT a token (Modal token is the backdrop) */
color: var(--white); /* fixed ink on the fixed-dark shell — --c-text-white
flips to metal-950 in dark scheme and would vanish */
box-shadow: var(--shadow-modal);
display: flex;
flex-direction: column;
align-items: center;
gap: 22px;
}
.pop-modal__close {
position: absolute;
top: 16px;
right: 16px;
width: 30px; height: 30px;
display: grid; place-items: center;
border-radius: 50%;
background: color-mix(in srgb, var(--metal-300) 12%, transparent);
box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--metal-300) 14%, transparent);
color: var(--white);
font-size: 1.6rem;
line-height: 1;
border: none;
cursor: pointer;
}
.pop-modal__title {
font-family: var(--ff-dm-sans);
font-weight: 900;
font-size: 2.6rem;
line-height: 1.1;
letter-spacing: -.03em;
text-align: center;
color: var(--white);
margin: 0;
}
.pop-modal__content {
width: 100%;
padding: 24px 20px;
border-radius: var(--r-md);
background: color-mix(in srgb, var(--metal-300) 12%, transparent);
box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--metal-300) 14%, transparent);
display: flex;
flex-direction: column;
align-items: center;
gap: 16px;
}
.pop-modal__icon {
font-size: 4.4rem;
line-height: 1;
}
.pop-modal__image {
width: 96px;
height: 96px;
object-fit: contain;
flex: none;
}
.pop-modal__description {
margin: 0;
text-align: center;
font-family: var(--ff-inter);
font-size: 1.4rem;
line-height: 1.5;
color: color-mix(in srgb, var(--white) 80%, transparent);
}
.pop-modal__bullets {
display: flex;
flex-direction: column;
gap: 8px;
width: 100%;
}
.pop-modal__footer {
display: flex;
gap: 12px;
width: 100%;
}
.pop-modal__footer .button {
flex: 1;
}
</style>
<style scoped>
.pop-modal__eyebrow {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 6px 14px 6px 12px;
border-radius: var(--r-pill);
background: color-mix(in srgb, var(--metal-300) 12%, transparent);
box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--metal-300) 14%, transparent);
font-family: var(--ff-inter);
font-weight: 700;
font-size: 1.3rem;
line-height: 1.4;
color: var(--white);
}
.pop-modal__eyebrow-dot {
width: 10px; height: 10px;
border-radius: 50%;
background: var(--green-600);
flex: none;
}
</style>
<style scoped>
.bullet-row {
display: flex;
align-items: flex-start;
gap: 10px;
font-family: var(--ff-inter);
font-weight: 400;
font-size: 1.4rem;
line-height: 1.5;
color: var(--c-text-secondary);
/* color override propagates to children — matches the app's `c:<color>/txt` from Text.vue */
&.c\:white\/txt {
color: var(--c-text-white);
}
}
/* Legacy check (check-thick_green.png <img> — same asset as Pill's
leading). New rows should use the `.check-circle` SVG instead; this
stays so older mockups keep rendering. */
.bullet-row__check {
flex: none;
width: 16px;
height: 16px;
margin-top: 2px;
}
/* SVG check inside a bullet row — nudge to sit on the text's optical
center like the <img> version does */
.bullet-row .check-circle {
margin-top: 2px;
}
.bullet-row__text {
flex: 1;
}
</style>
<style scoped>
.paywall-price {
width: 100%;
padding: 14px 16px;
border-radius: var(--r-md);
background: color-mix(in srgb, var(--metal-300) 12%, transparent);
box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--metal-300) 14%, transparent);
text-align: center;
line-height: 1.3;
}
.paywall-price__was {
margin-right: 8px;
font-size: 1.2rem;
color: color-mix(in srgb, var(--white) 50%, transparent);
text-decoration: line-through;
}
.paywall-price__now {
font-family: var(--ff-dm-sans);
font-weight: 900;
font-size: 1.9rem;
color: var(--white);
letter-spacing: -.02em;
}
.paywall-price__tag {
display: block;
margin-top: 4px;
font-family: var(--ff-inter);
font-weight: 700;
font-size: 1.05rem;
letter-spacing: .12em;
text-transform: uppercase;
color: var(--green-500);
}
</style>
<PopModal> · ui/components/pop/PopModal.vue<PrimaryButton> · ui/buttons/PrimaryButton.vue<Text> · ui/components/Text.vue<PopModalEyebrow> · catalog<BulletRow> · catalog<PaywallPrice> · catalog<PaywallModal> — new wrapper that composes the atoms above (Vue + scoped CSS below)<script setup>
import { computed } from 'vue'
import PopModal from '@/components/pop/PopModal.vue'
import PopModalEyebrow from '@/components/pop/PopModalEyebrow.vue'
import PrimaryButton from '@/buttons/PrimaryButton.vue'
import Text from '@/components/Text.vue'
import BulletRow from '@/components/BulletRow.vue'
import PaywallPrice from '@/components/PaywallPrice.vue'
const props = defineProps({
show: Boolean,
trigger: { type: String, required: true },
})
const emit = defineEmits(['update:show', 'create-account', 'go-pro'])
const PRO_IMG = 'https://cdn.joypixels.com/emojicopy/assets/marketing/crown.png'
const FAVORITES_IMG = 'https://cdn.joypixels.com/emojicopy/assets/marketing/upsell-favorites.png'
const BOX_DOWN_IMG = 'https://cdn.joypixels.com/emojicopy/assets/marketing/box-down.png'
// Single source of truth — mirrors the app's featureContent config.
const TRIGGERS = {
// -------- Account required (Free account upsell) --------
'account-required' : { type: 'account-required', image: PRO_IMG, title: 'Sign in to keep going', description: 'Enjoy early access with a free account.' },
'use-favorites' : { type: 'account-required', image: FAVORITES_IMG, title: 'Favorites need an account', description: 'Create your free account to start adding items to your Favorites.' },
'hide-action-bar' : { type: 'account-required', image: BOX_DOWN_IMG, title: 'Tweak the toolbar', description: 'Get more features — including hiding the action bar — with a free account.' },
'sort-collections' : { type: 'account-required', image: BOX_DOWN_IMG, title: 'Curate your collections', description: 'Curate from our growing set of in-demand emoji collections.' },
'unfollow-collection' : { type: 'account-required', image: BOX_DOWN_IMG, title: 'Unfollow with an account', description: 'Curate from our growing set of in-demand emoji collections.' },
'remove-items' : { type: 'account-required', image: BOX_DOWN_IMG, title: 'Tidy your recents', description: 'Curate your recent emoji by choosing one or more to remove.' },
// -------- Subscription required (Pro upgrade) -----------
// The bullets-layout variants (sort-smart, create-list, save-combo) supply an `icon` glyph
// + a `bullets` array instead of an image + description. The template branches on which
// shape is present.
'sort-smart' : { type: 'pro-required', icon: '✨', dismiss: 'Keep default', title: 'Smart sort, for you', bullets: [
'Surfaces the emoji you actually use',
'Adapts as your reactions evolve',
'Faster picks, fewer searches' ] },
'create-list' : { type: 'pro-required', icon: '📋', title: 'Build your own lists', bullets: [
'Group emoji by mood or project',
'Reorder, rename, follow lists',
'Unlimited lists with Pro' ] },
'sort-lists' : { type: 'pro-required', image: PRO_IMG, title: 'Add more lists', description: 'Create additional lists and access the perfect emoji more quickly.' },
'save-combo' : { type: 'pro-required', icon: '🧩', title: 'Save combos with Pro', bullets: [
'Keep your best emoji combos forever',
'One click to copy any saved combo',
'Sync across web, Chrome & desktop' ] },
'edit-list' : { type: 'pro-required', image: PRO_IMG, title: 'Rename + edit lists', description: 'Rename this list or create your own lists with a pro account.' },
'clone-collection' : { type: 'pro-required', image: PRO_IMG, title: 'Clone any collection', description: 'Use this as a starting point for your own custom list.' },
'delete-list' : { type: 'pro-required', image: PRO_IMG, title: 'Delete or customize', description: 'Remove this list, or customize it and make it your own.' },
};
const config = computed(() => TRIGGERS[props.trigger])
const isPro = computed(() => config.value.type === 'pro-required')
const offer = { was: '$32', now: '$16 / yr', tag: '50% off · founding' }
</script>
<template>
<PopModal :popped="show" width="380px"
@unpop="emit('update:show', false)">
<div class="pop-modal paywall-modal">
<button class="pop-modal__close"
@click="emit('update:show', false)">×</button>
<PopModalEyebrow>{{ isPro ? 'Pro feature' : 'Free account' }}</PopModalEyebrow>
<Text tag="h3" class="pop-modal__title" :text="config.title" />
<!-- Two layouts share the same `.pop-modal__content` shell:
(a) image + description, (b) icon + checked bullets. -->
<div class="pop-modal__content">
<template v-if="config.bullets">
<div class="pop-modal__icon">{{ config.icon }}</div>
<ul class="pop-modal__bullets">
<BulletRow v-for="b in config.bullets" :key="b"
color="white" :text="b" />
</ul>
</template>
<template v-else>
<img class="pop-modal__image" :src="config.image" alt="" />
<Text type="body/m-reg" class="pop-modal__description"
:text="config.description" />
</template>
</div>
<PaywallPrice v-if="isPro" :was="offer.was" :now="offer.now" :tag="offer.tag" />
<div class="pop-modal__footer">
<PrimaryButton variant="gray" size="md"
:label="config.dismiss ?? 'Not now'"
@click="emit('update:show', false)" />
<PrimaryButton v-if="isPro"
variant="green" size="md" label="Go Pro"
@click="emit('go-pro')" />
<PrimaryButton v-else
variant="green" size="md" label="Sign up"
@click="emit('create-account')" />
</div>
</div>
</PopModal>
</template>
<PaywallModal
v-model:show="modals.paywall"
trigger="save-combo"
@go-pro="checkout"
/>
<style scoped>
.pop-modal {
position: relative;
width: 100%;
max-width: 380px;
padding: 28px 24px 24px;
border-radius: var(--r-lg); /* 16px — matches designer */
background: var(--metal-950); /* #0F1012, NOT a token (Modal token is the backdrop) */
color: var(--white); /* fixed ink on the fixed-dark shell — --c-text-white
flips to metal-950 in dark scheme and would vanish */
box-shadow: var(--shadow-modal);
display: flex;
flex-direction: column;
align-items: center;
gap: 22px;
}
.pop-modal__close {
position: absolute;
top: 16px;
right: 16px;
width: 30px; height: 30px;
display: grid; place-items: center;
border-radius: 50%;
background: color-mix(in srgb, var(--metal-300) 12%, transparent);
box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--metal-300) 14%, transparent);
color: var(--white);
font-size: 1.6rem;
line-height: 1;
border: none;
cursor: pointer;
}
.pop-modal__title {
font-family: var(--ff-dm-sans);
font-weight: 900;
font-size: 2.6rem;
line-height: 1.1;
letter-spacing: -.03em;
text-align: center;
color: var(--white);
margin: 0;
}
.pop-modal__content {
width: 100%;
padding: 24px 20px;
border-radius: var(--r-md);
background: color-mix(in srgb, var(--metal-300) 12%, transparent);
box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--metal-300) 14%, transparent);
display: flex;
flex-direction: column;
align-items: center;
gap: 16px;
}
.pop-modal__icon {
font-size: 4.4rem;
line-height: 1;
}
.pop-modal__image {
width: 96px;
height: 96px;
object-fit: contain;
flex: none;
}
.pop-modal__description {
margin: 0;
text-align: center;
font-family: var(--ff-inter);
font-size: 1.4rem;
line-height: 1.5;
color: color-mix(in srgb, var(--white) 80%, transparent);
}
.pop-modal__bullets {
display: flex;
flex-direction: column;
gap: 8px;
width: 100%;
}
.pop-modal__footer {
display: flex;
gap: 12px;
width: 100%;
}
.pop-modal__footer .button {
flex: 1;
}
</style>
<style scoped>
.pop-modal__eyebrow {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 6px 14px 6px 12px;
border-radius: var(--r-pill);
background: color-mix(in srgb, var(--metal-300) 12%, transparent);
box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--metal-300) 14%, transparent);
font-family: var(--ff-inter);
font-weight: 700;
font-size: 1.3rem;
line-height: 1.4;
color: var(--white);
}
.pop-modal__eyebrow-dot {
width: 10px; height: 10px;
border-radius: 50%;
background: var(--green-600);
flex: none;
}
</style>
<style scoped>
.bullet-row {
display: flex;
align-items: flex-start;
gap: 10px;
font-family: var(--ff-inter);
font-weight: 400;
font-size: 1.4rem;
line-height: 1.5;
color: var(--c-text-secondary);
/* color override propagates to children — matches the app's `c:<color>/txt` from Text.vue */
&.c\:white\/txt {
color: var(--c-text-white);
}
}
/* Legacy check (check-thick_green.png <img> — same asset as Pill's
leading). New rows should use the `.check-circle` SVG instead; this
stays so older mockups keep rendering. */
.bullet-row__check {
flex: none;
width: 16px;
height: 16px;
margin-top: 2px;
}
/* SVG check inside a bullet row — nudge to sit on the text's optical
center like the <img> version does */
.bullet-row .check-circle {
margin-top: 2px;
}
.bullet-row__text {
flex: 1;
}
</style>
<style scoped>
.paywall-price {
width: 100%;
padding: 14px 16px;
border-radius: var(--r-md);
background: color-mix(in srgb, var(--metal-300) 12%, transparent);
box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--metal-300) 14%, transparent);
text-align: center;
line-height: 1.3;
}
.paywall-price__was {
margin-right: 8px;
font-size: 1.2rem;
color: color-mix(in srgb, var(--white) 50%, transparent);
text-decoration: line-through;
}
.paywall-price__now {
font-family: var(--ff-dm-sans);
font-weight: 900;
font-size: 1.9rem;
color: var(--white);
letter-spacing: -.02em;
}
.paywall-price__tag {
display: block;
margin-top: 4px;
font-family: var(--ff-inter);
font-weight: 700;
font-size: 1.05rem;
letter-spacing: .12em;
text-transform: uppercase;
color: var(--green-500);
}
</style>
Rename this list or create your own lists with a pro account.
<PopModal> · ui/components/pop/PopModal.vue<PrimaryButton> · ui/buttons/PrimaryButton.vue<Text> · ui/components/Text.vue<PopModalEyebrow> · catalog<BulletRow> · catalog<PaywallPrice> · catalog<PaywallModal> — new wrapper that composes the atoms above (Vue + scoped CSS below)<script setup>
import { computed } from 'vue'
import PopModal from '@/components/pop/PopModal.vue'
import PopModalEyebrow from '@/components/pop/PopModalEyebrow.vue'
import PrimaryButton from '@/buttons/PrimaryButton.vue'
import Text from '@/components/Text.vue'
import BulletRow from '@/components/BulletRow.vue'
import PaywallPrice from '@/components/PaywallPrice.vue'
const props = defineProps({
show: Boolean,
trigger: { type: String, required: true },
})
const emit = defineEmits(['update:show', 'create-account', 'go-pro'])
const PRO_IMG = 'https://cdn.joypixels.com/emojicopy/assets/marketing/crown.png'
const FAVORITES_IMG = 'https://cdn.joypixels.com/emojicopy/assets/marketing/upsell-favorites.png'
const BOX_DOWN_IMG = 'https://cdn.joypixels.com/emojicopy/assets/marketing/box-down.png'
// Single source of truth — mirrors the app's featureContent config.
const TRIGGERS = {
// -------- Account required (Free account upsell) --------
'account-required' : { type: 'account-required', image: PRO_IMG, title: 'Sign in to keep going', description: 'Enjoy early access with a free account.' },
'use-favorites' : { type: 'account-required', image: FAVORITES_IMG, title: 'Favorites need an account', description: 'Create your free account to start adding items to your Favorites.' },
'hide-action-bar' : { type: 'account-required', image: BOX_DOWN_IMG, title: 'Tweak the toolbar', description: 'Get more features — including hiding the action bar — with a free account.' },
'sort-collections' : { type: 'account-required', image: BOX_DOWN_IMG, title: 'Curate your collections', description: 'Curate from our growing set of in-demand emoji collections.' },
'unfollow-collection' : { type: 'account-required', image: BOX_DOWN_IMG, title: 'Unfollow with an account', description: 'Curate from our growing set of in-demand emoji collections.' },
'remove-items' : { type: 'account-required', image: BOX_DOWN_IMG, title: 'Tidy your recents', description: 'Curate your recent emoji by choosing one or more to remove.' },
// -------- Subscription required (Pro upgrade) -----------
// The bullets-layout variants (sort-smart, create-list, save-combo) supply an `icon` glyph
// + a `bullets` array instead of an image + description. The template branches on which
// shape is present.
'sort-smart' : { type: 'pro-required', icon: '✨', dismiss: 'Keep default', title: 'Smart sort, for you', bullets: [
'Surfaces the emoji you actually use',
'Adapts as your reactions evolve',
'Faster picks, fewer searches' ] },
'create-list' : { type: 'pro-required', icon: '📋', title: 'Build your own lists', bullets: [
'Group emoji by mood or project',
'Reorder, rename, follow lists',
'Unlimited lists with Pro' ] },
'sort-lists' : { type: 'pro-required', image: PRO_IMG, title: 'Add more lists', description: 'Create additional lists and access the perfect emoji more quickly.' },
'save-combo' : { type: 'pro-required', icon: '🧩', title: 'Save combos with Pro', bullets: [
'Keep your best emoji combos forever',
'One click to copy any saved combo',
'Sync across web, Chrome & desktop' ] },
'edit-list' : { type: 'pro-required', image: PRO_IMG, title: 'Rename + edit lists', description: 'Rename this list or create your own lists with a pro account.' },
'clone-collection' : { type: 'pro-required', image: PRO_IMG, title: 'Clone any collection', description: 'Use this as a starting point for your own custom list.' },
'delete-list' : { type: 'pro-required', image: PRO_IMG, title: 'Delete or customize', description: 'Remove this list, or customize it and make it your own.' },
};
const config = computed(() => TRIGGERS[props.trigger])
const isPro = computed(() => config.value.type === 'pro-required')
const offer = { was: '$32', now: '$16 / yr', tag: '50% off · founding' }
</script>
<template>
<PopModal :popped="show" width="380px"
@unpop="emit('update:show', false)">
<div class="pop-modal paywall-modal">
<button class="pop-modal__close"
@click="emit('update:show', false)">×</button>
<PopModalEyebrow>{{ isPro ? 'Pro feature' : 'Free account' }}</PopModalEyebrow>
<Text tag="h3" class="pop-modal__title" :text="config.title" />
<!-- Two layouts share the same `.pop-modal__content` shell:
(a) image + description, (b) icon + checked bullets. -->
<div class="pop-modal__content">
<template v-if="config.bullets">
<div class="pop-modal__icon">{{ config.icon }}</div>
<ul class="pop-modal__bullets">
<BulletRow v-for="b in config.bullets" :key="b"
color="white" :text="b" />
</ul>
</template>
<template v-else>
<img class="pop-modal__image" :src="config.image" alt="" />
<Text type="body/m-reg" class="pop-modal__description"
:text="config.description" />
</template>
</div>
<PaywallPrice v-if="isPro" :was="offer.was" :now="offer.now" :tag="offer.tag" />
<div class="pop-modal__footer">
<PrimaryButton variant="gray" size="md"
:label="config.dismiss ?? 'Not now'"
@click="emit('update:show', false)" />
<PrimaryButton v-if="isPro"
variant="green" size="md" label="Go Pro"
@click="emit('go-pro')" />
<PrimaryButton v-else
variant="green" size="md" label="Sign up"
@click="emit('create-account')" />
</div>
</div>
</PopModal>
</template>
<PaywallModal
v-model:show="modals.paywall"
trigger="edit-list"
@go-pro="checkout"
/>
<style scoped>
.pop-modal {
position: relative;
width: 100%;
max-width: 380px;
padding: 28px 24px 24px;
border-radius: var(--r-lg); /* 16px — matches designer */
background: var(--metal-950); /* #0F1012, NOT a token (Modal token is the backdrop) */
color: var(--white); /* fixed ink on the fixed-dark shell — --c-text-white
flips to metal-950 in dark scheme and would vanish */
box-shadow: var(--shadow-modal);
display: flex;
flex-direction: column;
align-items: center;
gap: 22px;
}
.pop-modal__close {
position: absolute;
top: 16px;
right: 16px;
width: 30px; height: 30px;
display: grid; place-items: center;
border-radius: 50%;
background: color-mix(in srgb, var(--metal-300) 12%, transparent);
box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--metal-300) 14%, transparent);
color: var(--white);
font-size: 1.6rem;
line-height: 1;
border: none;
cursor: pointer;
}
.pop-modal__title {
font-family: var(--ff-dm-sans);
font-weight: 900;
font-size: 2.6rem;
line-height: 1.1;
letter-spacing: -.03em;
text-align: center;
color: var(--white);
margin: 0;
}
.pop-modal__content {
width: 100%;
padding: 24px 20px;
border-radius: var(--r-md);
background: color-mix(in srgb, var(--metal-300) 12%, transparent);
box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--metal-300) 14%, transparent);
display: flex;
flex-direction: column;
align-items: center;
gap: 16px;
}
.pop-modal__icon {
font-size: 4.4rem;
line-height: 1;
}
.pop-modal__image {
width: 96px;
height: 96px;
object-fit: contain;
flex: none;
}
.pop-modal__description {
margin: 0;
text-align: center;
font-family: var(--ff-inter);
font-size: 1.4rem;
line-height: 1.5;
color: color-mix(in srgb, var(--white) 80%, transparent);
}
.pop-modal__bullets {
display: flex;
flex-direction: column;
gap: 8px;
width: 100%;
}
.pop-modal__footer {
display: flex;
gap: 12px;
width: 100%;
}
.pop-modal__footer .button {
flex: 1;
}
</style>
<style scoped>
.pop-modal__eyebrow {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 6px 14px 6px 12px;
border-radius: var(--r-pill);
background: color-mix(in srgb, var(--metal-300) 12%, transparent);
box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--metal-300) 14%, transparent);
font-family: var(--ff-inter);
font-weight: 700;
font-size: 1.3rem;
line-height: 1.4;
color: var(--white);
}
.pop-modal__eyebrow-dot {
width: 10px; height: 10px;
border-radius: 50%;
background: var(--green-600);
flex: none;
}
</style>
<style scoped>
.bullet-row {
display: flex;
align-items: flex-start;
gap: 10px;
font-family: var(--ff-inter);
font-weight: 400;
font-size: 1.4rem;
line-height: 1.5;
color: var(--c-text-secondary);
/* color override propagates to children — matches the app's `c:<color>/txt` from Text.vue */
&.c\:white\/txt {
color: var(--c-text-white);
}
}
/* Legacy check (check-thick_green.png <img> — same asset as Pill's
leading). New rows should use the `.check-circle` SVG instead; this
stays so older mockups keep rendering. */
.bullet-row__check {
flex: none;
width: 16px;
height: 16px;
margin-top: 2px;
}
/* SVG check inside a bullet row — nudge to sit on the text's optical
center like the <img> version does */
.bullet-row .check-circle {
margin-top: 2px;
}
.bullet-row__text {
flex: 1;
}
</style>
<style scoped>
.paywall-price {
width: 100%;
padding: 14px 16px;
border-radius: var(--r-md);
background: color-mix(in srgb, var(--metal-300) 12%, transparent);
box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--metal-300) 14%, transparent);
text-align: center;
line-height: 1.3;
}
.paywall-price__was {
margin-right: 8px;
font-size: 1.2rem;
color: color-mix(in srgb, var(--white) 50%, transparent);
text-decoration: line-through;
}
.paywall-price__now {
font-family: var(--ff-dm-sans);
font-weight: 900;
font-size: 1.9rem;
color: var(--white);
letter-spacing: -.02em;
}
.paywall-price__tag {
display: block;
margin-top: 4px;
font-family: var(--ff-inter);
font-weight: 700;
font-size: 1.05rem;
letter-spacing: .12em;
text-transform: uppercase;
color: var(--green-500);
}
</style>
Use this as a starting point for your own custom list.
<PopModal> · ui/components/pop/PopModal.vue<PrimaryButton> · ui/buttons/PrimaryButton.vue<Text> · ui/components/Text.vue<PopModalEyebrow> · catalog<BulletRow> · catalog<PaywallPrice> · catalog<PaywallModal> — new wrapper that composes the atoms above (Vue + scoped CSS below)<script setup>
import { computed } from 'vue'
import PopModal from '@/components/pop/PopModal.vue'
import PopModalEyebrow from '@/components/pop/PopModalEyebrow.vue'
import PrimaryButton from '@/buttons/PrimaryButton.vue'
import Text from '@/components/Text.vue'
import BulletRow from '@/components/BulletRow.vue'
import PaywallPrice from '@/components/PaywallPrice.vue'
const props = defineProps({
show: Boolean,
trigger: { type: String, required: true },
})
const emit = defineEmits(['update:show', 'create-account', 'go-pro'])
const PRO_IMG = 'https://cdn.joypixels.com/emojicopy/assets/marketing/crown.png'
const FAVORITES_IMG = 'https://cdn.joypixels.com/emojicopy/assets/marketing/upsell-favorites.png'
const BOX_DOWN_IMG = 'https://cdn.joypixels.com/emojicopy/assets/marketing/box-down.png'
// Single source of truth — mirrors the app's featureContent config.
const TRIGGERS = {
// -------- Account required (Free account upsell) --------
'account-required' : { type: 'account-required', image: PRO_IMG, title: 'Sign in to keep going', description: 'Enjoy early access with a free account.' },
'use-favorites' : { type: 'account-required', image: FAVORITES_IMG, title: 'Favorites need an account', description: 'Create your free account to start adding items to your Favorites.' },
'hide-action-bar' : { type: 'account-required', image: BOX_DOWN_IMG, title: 'Tweak the toolbar', description: 'Get more features — including hiding the action bar — with a free account.' },
'sort-collections' : { type: 'account-required', image: BOX_DOWN_IMG, title: 'Curate your collections', description: 'Curate from our growing set of in-demand emoji collections.' },
'unfollow-collection' : { type: 'account-required', image: BOX_DOWN_IMG, title: 'Unfollow with an account', description: 'Curate from our growing set of in-demand emoji collections.' },
'remove-items' : { type: 'account-required', image: BOX_DOWN_IMG, title: 'Tidy your recents', description: 'Curate your recent emoji by choosing one or more to remove.' },
// -------- Subscription required (Pro upgrade) -----------
// The bullets-layout variants (sort-smart, create-list, save-combo) supply an `icon` glyph
// + a `bullets` array instead of an image + description. The template branches on which
// shape is present.
'sort-smart' : { type: 'pro-required', icon: '✨', dismiss: 'Keep default', title: 'Smart sort, for you', bullets: [
'Surfaces the emoji you actually use',
'Adapts as your reactions evolve',
'Faster picks, fewer searches' ] },
'create-list' : { type: 'pro-required', icon: '📋', title: 'Build your own lists', bullets: [
'Group emoji by mood or project',
'Reorder, rename, follow lists',
'Unlimited lists with Pro' ] },
'sort-lists' : { type: 'pro-required', image: PRO_IMG, title: 'Add more lists', description: 'Create additional lists and access the perfect emoji more quickly.' },
'save-combo' : { type: 'pro-required', icon: '🧩', title: 'Save combos with Pro', bullets: [
'Keep your best emoji combos forever',
'One click to copy any saved combo',
'Sync across web, Chrome & desktop' ] },
'edit-list' : { type: 'pro-required', image: PRO_IMG, title: 'Rename + edit lists', description: 'Rename this list or create your own lists with a pro account.' },
'clone-collection' : { type: 'pro-required', image: PRO_IMG, title: 'Clone any collection', description: 'Use this as a starting point for your own custom list.' },
'delete-list' : { type: 'pro-required', image: PRO_IMG, title: 'Delete or customize', description: 'Remove this list, or customize it and make it your own.' },
};
const config = computed(() => TRIGGERS[props.trigger])
const isPro = computed(() => config.value.type === 'pro-required')
const offer = { was: '$32', now: '$16 / yr', tag: '50% off · founding' }
</script>
<template>
<PopModal :popped="show" width="380px"
@unpop="emit('update:show', false)">
<div class="pop-modal paywall-modal">
<button class="pop-modal__close"
@click="emit('update:show', false)">×</button>
<PopModalEyebrow>{{ isPro ? 'Pro feature' : 'Free account' }}</PopModalEyebrow>
<Text tag="h3" class="pop-modal__title" :text="config.title" />
<!-- Two layouts share the same `.pop-modal__content` shell:
(a) image + description, (b) icon + checked bullets. -->
<div class="pop-modal__content">
<template v-if="config.bullets">
<div class="pop-modal__icon">{{ config.icon }}</div>
<ul class="pop-modal__bullets">
<BulletRow v-for="b in config.bullets" :key="b"
color="white" :text="b" />
</ul>
</template>
<template v-else>
<img class="pop-modal__image" :src="config.image" alt="" />
<Text type="body/m-reg" class="pop-modal__description"
:text="config.description" />
</template>
</div>
<PaywallPrice v-if="isPro" :was="offer.was" :now="offer.now" :tag="offer.tag" />
<div class="pop-modal__footer">
<PrimaryButton variant="gray" size="md"
:label="config.dismiss ?? 'Not now'"
@click="emit('update:show', false)" />
<PrimaryButton v-if="isPro"
variant="green" size="md" label="Go Pro"
@click="emit('go-pro')" />
<PrimaryButton v-else
variant="green" size="md" label="Sign up"
@click="emit('create-account')" />
</div>
</div>
</PopModal>
</template>
<PaywallModal
v-model:show="modals.paywall"
trigger="clone-collection"
@go-pro="checkout"
/>
<style scoped>
.pop-modal {
position: relative;
width: 100%;
max-width: 380px;
padding: 28px 24px 24px;
border-radius: var(--r-lg); /* 16px — matches designer */
background: var(--metal-950); /* #0F1012, NOT a token (Modal token is the backdrop) */
color: var(--white); /* fixed ink on the fixed-dark shell — --c-text-white
flips to metal-950 in dark scheme and would vanish */
box-shadow: var(--shadow-modal);
display: flex;
flex-direction: column;
align-items: center;
gap: 22px;
}
.pop-modal__close {
position: absolute;
top: 16px;
right: 16px;
width: 30px; height: 30px;
display: grid; place-items: center;
border-radius: 50%;
background: color-mix(in srgb, var(--metal-300) 12%, transparent);
box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--metal-300) 14%, transparent);
color: var(--white);
font-size: 1.6rem;
line-height: 1;
border: none;
cursor: pointer;
}
.pop-modal__title {
font-family: var(--ff-dm-sans);
font-weight: 900;
font-size: 2.6rem;
line-height: 1.1;
letter-spacing: -.03em;
text-align: center;
color: var(--white);
margin: 0;
}
.pop-modal__content {
width: 100%;
padding: 24px 20px;
border-radius: var(--r-md);
background: color-mix(in srgb, var(--metal-300) 12%, transparent);
box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--metal-300) 14%, transparent);
display: flex;
flex-direction: column;
align-items: center;
gap: 16px;
}
.pop-modal__icon {
font-size: 4.4rem;
line-height: 1;
}
.pop-modal__image {
width: 96px;
height: 96px;
object-fit: contain;
flex: none;
}
.pop-modal__description {
margin: 0;
text-align: center;
font-family: var(--ff-inter);
font-size: 1.4rem;
line-height: 1.5;
color: color-mix(in srgb, var(--white) 80%, transparent);
}
.pop-modal__bullets {
display: flex;
flex-direction: column;
gap: 8px;
width: 100%;
}
.pop-modal__footer {
display: flex;
gap: 12px;
width: 100%;
}
.pop-modal__footer .button {
flex: 1;
}
</style>
<style scoped>
.pop-modal__eyebrow {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 6px 14px 6px 12px;
border-radius: var(--r-pill);
background: color-mix(in srgb, var(--metal-300) 12%, transparent);
box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--metal-300) 14%, transparent);
font-family: var(--ff-inter);
font-weight: 700;
font-size: 1.3rem;
line-height: 1.4;
color: var(--white);
}
.pop-modal__eyebrow-dot {
width: 10px; height: 10px;
border-radius: 50%;
background: var(--green-600);
flex: none;
}
</style>
<style scoped>
.bullet-row {
display: flex;
align-items: flex-start;
gap: 10px;
font-family: var(--ff-inter);
font-weight: 400;
font-size: 1.4rem;
line-height: 1.5;
color: var(--c-text-secondary);
/* color override propagates to children — matches the app's `c:<color>/txt` from Text.vue */
&.c\:white\/txt {
color: var(--c-text-white);
}
}
/* Legacy check (check-thick_green.png <img> — same asset as Pill's
leading). New rows should use the `.check-circle` SVG instead; this
stays so older mockups keep rendering. */
.bullet-row__check {
flex: none;
width: 16px;
height: 16px;
margin-top: 2px;
}
/* SVG check inside a bullet row — nudge to sit on the text's optical
center like the <img> version does */
.bullet-row .check-circle {
margin-top: 2px;
}
.bullet-row__text {
flex: 1;
}
</style>
<style scoped>
.paywall-price {
width: 100%;
padding: 14px 16px;
border-radius: var(--r-md);
background: color-mix(in srgb, var(--metal-300) 12%, transparent);
box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--metal-300) 14%, transparent);
text-align: center;
line-height: 1.3;
}
.paywall-price__was {
margin-right: 8px;
font-size: 1.2rem;
color: color-mix(in srgb, var(--white) 50%, transparent);
text-decoration: line-through;
}
.paywall-price__now {
font-family: var(--ff-dm-sans);
font-weight: 900;
font-size: 1.9rem;
color: var(--white);
letter-spacing: -.02em;
}
.paywall-price__tag {
display: block;
margin-top: 4px;
font-family: var(--ff-inter);
font-weight: 700;
font-size: 1.05rem;
letter-spacing: .12em;
text-transform: uppercase;
color: var(--green-500);
}
</style>
Remove this list, or customize it and make it your own.
<PopModal> · ui/components/pop/PopModal.vue<PrimaryButton> · ui/buttons/PrimaryButton.vue<Text> · ui/components/Text.vue<PopModalEyebrow> · catalog<BulletRow> · catalog<PaywallPrice> · catalog<PaywallModal> — new wrapper that composes the atoms above (Vue + scoped CSS below)<script setup>
import { computed } from 'vue'
import PopModal from '@/components/pop/PopModal.vue'
import PopModalEyebrow from '@/components/pop/PopModalEyebrow.vue'
import PrimaryButton from '@/buttons/PrimaryButton.vue'
import Text from '@/components/Text.vue'
import BulletRow from '@/components/BulletRow.vue'
import PaywallPrice from '@/components/PaywallPrice.vue'
const props = defineProps({
show: Boolean,
trigger: { type: String, required: true },
})
const emit = defineEmits(['update:show', 'create-account', 'go-pro'])
const PRO_IMG = 'https://cdn.joypixels.com/emojicopy/assets/marketing/crown.png'
const FAVORITES_IMG = 'https://cdn.joypixels.com/emojicopy/assets/marketing/upsell-favorites.png'
const BOX_DOWN_IMG = 'https://cdn.joypixels.com/emojicopy/assets/marketing/box-down.png'
// Single source of truth — mirrors the app's featureContent config.
const TRIGGERS = {
// -------- Account required (Free account upsell) --------
'account-required' : { type: 'account-required', image: PRO_IMG, title: 'Sign in to keep going', description: 'Enjoy early access with a free account.' },
'use-favorites' : { type: 'account-required', image: FAVORITES_IMG, title: 'Favorites need an account', description: 'Create your free account to start adding items to your Favorites.' },
'hide-action-bar' : { type: 'account-required', image: BOX_DOWN_IMG, title: 'Tweak the toolbar', description: 'Get more features — including hiding the action bar — with a free account.' },
'sort-collections' : { type: 'account-required', image: BOX_DOWN_IMG, title: 'Curate your collections', description: 'Curate from our growing set of in-demand emoji collections.' },
'unfollow-collection' : { type: 'account-required', image: BOX_DOWN_IMG, title: 'Unfollow with an account', description: 'Curate from our growing set of in-demand emoji collections.' },
'remove-items' : { type: 'account-required', image: BOX_DOWN_IMG, title: 'Tidy your recents', description: 'Curate your recent emoji by choosing one or more to remove.' },
// -------- Subscription required (Pro upgrade) -----------
// The bullets-layout variants (sort-smart, create-list, save-combo) supply an `icon` glyph
// + a `bullets` array instead of an image + description. The template branches on which
// shape is present.
'sort-smart' : { type: 'pro-required', icon: '✨', dismiss: 'Keep default', title: 'Smart sort, for you', bullets: [
'Surfaces the emoji you actually use',
'Adapts as your reactions evolve',
'Faster picks, fewer searches' ] },
'create-list' : { type: 'pro-required', icon: '📋', title: 'Build your own lists', bullets: [
'Group emoji by mood or project',
'Reorder, rename, follow lists',
'Unlimited lists with Pro' ] },
'sort-lists' : { type: 'pro-required', image: PRO_IMG, title: 'Add more lists', description: 'Create additional lists and access the perfect emoji more quickly.' },
'save-combo' : { type: 'pro-required', icon: '🧩', title: 'Save combos with Pro', bullets: [
'Keep your best emoji combos forever',
'One click to copy any saved combo',
'Sync across web, Chrome & desktop' ] },
'edit-list' : { type: 'pro-required', image: PRO_IMG, title: 'Rename + edit lists', description: 'Rename this list or create your own lists with a pro account.' },
'clone-collection' : { type: 'pro-required', image: PRO_IMG, title: 'Clone any collection', description: 'Use this as a starting point for your own custom list.' },
'delete-list' : { type: 'pro-required', image: PRO_IMG, title: 'Delete or customize', description: 'Remove this list, or customize it and make it your own.' },
};
const config = computed(() => TRIGGERS[props.trigger])
const isPro = computed(() => config.value.type === 'pro-required')
const offer = { was: '$32', now: '$16 / yr', tag: '50% off · founding' }
</script>
<template>
<PopModal :popped="show" width="380px"
@unpop="emit('update:show', false)">
<div class="pop-modal paywall-modal">
<button class="pop-modal__close"
@click="emit('update:show', false)">×</button>
<PopModalEyebrow>{{ isPro ? 'Pro feature' : 'Free account' }}</PopModalEyebrow>
<Text tag="h3" class="pop-modal__title" :text="config.title" />
<!-- Two layouts share the same `.pop-modal__content` shell:
(a) image + description, (b) icon + checked bullets. -->
<div class="pop-modal__content">
<template v-if="config.bullets">
<div class="pop-modal__icon">{{ config.icon }}</div>
<ul class="pop-modal__bullets">
<BulletRow v-for="b in config.bullets" :key="b"
color="white" :text="b" />
</ul>
</template>
<template v-else>
<img class="pop-modal__image" :src="config.image" alt="" />
<Text type="body/m-reg" class="pop-modal__description"
:text="config.description" />
</template>
</div>
<PaywallPrice v-if="isPro" :was="offer.was" :now="offer.now" :tag="offer.tag" />
<div class="pop-modal__footer">
<PrimaryButton variant="gray" size="md"
:label="config.dismiss ?? 'Not now'"
@click="emit('update:show', false)" />
<PrimaryButton v-if="isPro"
variant="green" size="md" label="Go Pro"
@click="emit('go-pro')" />
<PrimaryButton v-else
variant="green" size="md" label="Sign up"
@click="emit('create-account')" />
</div>
</div>
</PopModal>
</template>
<PaywallModal
v-model:show="modals.paywall"
trigger="delete-list"
@go-pro="checkout"
/>
<style scoped>
.pop-modal {
position: relative;
width: 100%;
max-width: 380px;
padding: 28px 24px 24px;
border-radius: var(--r-lg); /* 16px — matches designer */
background: var(--metal-950); /* #0F1012, NOT a token (Modal token is the backdrop) */
color: var(--white); /* fixed ink on the fixed-dark shell — --c-text-white
flips to metal-950 in dark scheme and would vanish */
box-shadow: var(--shadow-modal);
display: flex;
flex-direction: column;
align-items: center;
gap: 22px;
}
.pop-modal__close {
position: absolute;
top: 16px;
right: 16px;
width: 30px; height: 30px;
display: grid; place-items: center;
border-radius: 50%;
background: color-mix(in srgb, var(--metal-300) 12%, transparent);
box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--metal-300) 14%, transparent);
color: var(--white);
font-size: 1.6rem;
line-height: 1;
border: none;
cursor: pointer;
}
.pop-modal__title {
font-family: var(--ff-dm-sans);
font-weight: 900;
font-size: 2.6rem;
line-height: 1.1;
letter-spacing: -.03em;
text-align: center;
color: var(--white);
margin: 0;
}
.pop-modal__content {
width: 100%;
padding: 24px 20px;
border-radius: var(--r-md);
background: color-mix(in srgb, var(--metal-300) 12%, transparent);
box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--metal-300) 14%, transparent);
display: flex;
flex-direction: column;
align-items: center;
gap: 16px;
}
.pop-modal__icon {
font-size: 4.4rem;
line-height: 1;
}
.pop-modal__image {
width: 96px;
height: 96px;
object-fit: contain;
flex: none;
}
.pop-modal__description {
margin: 0;
text-align: center;
font-family: var(--ff-inter);
font-size: 1.4rem;
line-height: 1.5;
color: color-mix(in srgb, var(--white) 80%, transparent);
}
.pop-modal__bullets {
display: flex;
flex-direction: column;
gap: 8px;
width: 100%;
}
.pop-modal__footer {
display: flex;
gap: 12px;
width: 100%;
}
.pop-modal__footer .button {
flex: 1;
}
</style>
<style scoped>
.pop-modal__eyebrow {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 6px 14px 6px 12px;
border-radius: var(--r-pill);
background: color-mix(in srgb, var(--metal-300) 12%, transparent);
box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--metal-300) 14%, transparent);
font-family: var(--ff-inter);
font-weight: 700;
font-size: 1.3rem;
line-height: 1.4;
color: var(--white);
}
.pop-modal__eyebrow-dot {
width: 10px; height: 10px;
border-radius: 50%;
background: var(--green-600);
flex: none;
}
</style>
<style scoped>
.bullet-row {
display: flex;
align-items: flex-start;
gap: 10px;
font-family: var(--ff-inter);
font-weight: 400;
font-size: 1.4rem;
line-height: 1.5;
color: var(--c-text-secondary);
/* color override propagates to children — matches the app's `c:<color>/txt` from Text.vue */
&.c\:white\/txt {
color: var(--c-text-white);
}
}
/* Legacy check (check-thick_green.png <img> — same asset as Pill's
leading). New rows should use the `.check-circle` SVG instead; this
stays so older mockups keep rendering. */
.bullet-row__check {
flex: none;
width: 16px;
height: 16px;
margin-top: 2px;
}
/* SVG check inside a bullet row — nudge to sit on the text's optical
center like the <img> version does */
.bullet-row .check-circle {
margin-top: 2px;
}
.bullet-row__text {
flex: 1;
}
</style>
<style scoped>
.paywall-price {
width: 100%;
padding: 14px 16px;
border-radius: var(--r-md);
background: color-mix(in srgb, var(--metal-300) 12%, transparent);
box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--metal-300) 14%, transparent);
text-align: center;
line-height: 1.3;
}
.paywall-price__was {
margin-right: 8px;
font-size: 1.2rem;
color: color-mix(in srgb, var(--white) 50%, transparent);
text-decoration: line-through;
}
.paywall-price__now {
font-family: var(--ff-dm-sans);
font-weight: 900;
font-size: 1.9rem;
color: var(--white);
letter-spacing: -.02em;
}
.paywall-price__tag {
display: block;
margin-top: 4px;
font-family: var(--ff-inter);
font-weight: 700;
font-size: 1.05rem;
letter-spacing: .12em;
text-transform: uppercase;
color: var(--green-500);
}
</style>