What a Free user sees when they tap Follow with all 5 collection slots used, answered with a modal instead of a disabled button.
<SmallButton> + the live Pro offer modal (pro-modal.html E)<!-- ui/pages/Explore.vue — toggleFollow branches at the cap
instead of the button dying. ListRow's :disabled loses the
`!props.canFollow` term for authenticated users; canFollow
still drives the branch below. -->
const toggleFollow = (collection) => {
if (lists.isFollowing(collection.id)) return unfollow(collection)
if (!account.isPro && lists.followed.length >= FREE_COLLECTION_CAP) {
upsell.updateShowPro(true) // ← the shortcut: Pro offer modal
return // (swaps to FollowLimitModal later)
}
followList(collection.id)
}
<FollowLimitModal>Free fits 5 collections. Swap one out to make room for Paw Pack, or go Pro for unlimited.
<FollowLimitModal> — new, on the existing PopModal shell<PopModal> · <PrimaryButton> · <Text> · list store + useEmojiService<!-- ui/modals/FollowLimitModal.vue — NEW. Dark PopModal shell.
The slots row is the user's REAL followed collections: icons via
useEmojiService.getEmojiCharacters(collection.icon), same call
ListRow.vue makes. `collection` = the one they just tapped. -->
<script setup>
const props = defineProps(['show', 'collection']) // collection = the wanted one
const emit = defineEmits(['manage', 'go-pro', 'close'])
const lists = useListStore()
const slots = computed(() => lists.followed.map(c =>
useEmojiService.getEmojiCharacters(c.icon || '1f600').join('')))
const wantedIcon = computed(() =>
useEmojiService.getEmojiCharacters(props.collection?.icon || '1f600').join(''))
</script>
<template>
<PopModal :show="props.show" @close="emit('close')">
<div class="pop-modal__eyebrow">
<span class="pop-modal__eyebrow-dot"></span>
{{ slots.length }} of {{ FREE_COLLECTION_CAP }} collections
</div>
<Text tag="h3" class="pop-modal__title" text="Your collections are full" />
<div class="pop-modal__content">
<div class="follow-limit__slots" aria-hidden="true">
<span v-for="icon in slots" class="follow-limit__slot jp-font">{{ icon }}</span>
<span class="follow-limit__plus">+</span>
<span class="follow-limit__slot follow-limit__slot--wanted jp-font">{{ wantedIcon }}</span>
</div>
<p class="pop-modal__description">
Free fits {{ FREE_COLLECTION_CAP }} collections. Swap one out to make room
for <b class="follow-limit__name">{{ props.collection.title }}</b>,
or go Pro for unlimited.
</p>
</div>
<div class="pop-modal__footer">
<PrimaryButton variant="gray" size="md" label="Swap one out" @click="emit('manage')" />
<PrimaryButton variant="green" size="md" label="Go unlimited" @click="emit('go-pro')" />
</div>
</PopModal>
</template>
<!-- parent (Explore.vue) — replaces the shortcut branch: -->
<FollowLimitModal
v-model:show="modals.followLimit"
:collection="wantedCollection"
@manage="openManageCollections"
@go-pro="checkout"
/>
<style scoped>
/* FollowLimitModal — composition CSS. Slots visual: 5 filled tiles
(the user's followed collection icons, always jp-font) + a "+" +
one dashed WANTED tile holding the collection they just tapped.
All decorative — aria-hidden, deletable without reflow. */
.follow-limit .pop-modal__content {
gap: 18px;
}
.follow-limit__slots {
display: flex;
align-items: center;
justify-content: center;
gap: 7px;
}
.follow-limit__slot {
display: flex;
align-items: center;
justify-content: center;
width: 42px;
height: 42px;
border-radius: var(--r-md);
background: rgba(255, 255, 255, .06);
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, .10);
font-size: 2.1rem;
line-height: 1;
flex: none;
}
.follow-limit__plus {
margin: 0 1px;
font-family: var(--ff-dm-sans);
font-weight: 900;
font-size: 1.7rem;
line-height: 1;
color: color-mix(in srgb, var(--white) 45%, transparent);
flex: none;
}
/* the collection they just tapped — waiting on a slot, not locked out */
.follow-limit__slot--wanted {
background: color-mix(in srgb, var(--green-600) 10%, transparent);
box-shadow: none;
border: 1.5px dashed color-mix(in srgb, var(--green-600) 55%, transparent);
rotate: -6deg;
transition: rotate .2s;
animation: wanted-float 3.2s ease-in-out infinite;
}
.follow-limit:hover .follow-limit__slot--wanted { rotate: 0deg; }
@keyframes wanted-float {
0%, 100% { translate: 0 0; }
50% { translate: 0 -3px; }
}
@media (prefers-reduced-motion: reduce) {
.follow-limit__slot--wanted { animation: none; }
}
/* collection name inside the description reads at full ink */
.follow-limit__name {
color: var(--white);
font-weight: 700;
}
/* mobile — the mockup forces these via a `--m` modifier because a
375px stage frame can't trigger a real media query; in the app
they live in this ONE block */
@media (max-width: 800px) {
.follow-limit {
max-width: 100%;
padding: 26px 18px 18px;
}
.follow-limit .pop-modal__title {
font-size: 2.3rem;
}
.follow-limit .follow-limit__slot {
width: 37px;
height: 37px;
font-size: 1.85rem;
}
}
</style>
You're following 5 of 5 collections. Swap one out, or go Pro and follow every collection you like.
<FollowLimitModal> — new, on the existing PopModal shell<PopModal> · <PrimaryButton> · <Text> · list store + useEmojiService<!-- ui/modals/FollowLimitModal.vue — NEW. Dark PopModal shell.
Same component as card A — only the title + description strings
differ (B leads with their intent); full source below. The slots
row is the user's REAL followed collections: icons via
useEmojiService.getEmojiCharacters(collection.icon), same call
ListRow.vue makes. `collection` = the one they just tapped. -->
<script setup>
const props = defineProps(['show', 'collection']) // collection = the wanted one
const emit = defineEmits(['manage', 'go-pro', 'close'])
const lists = useListStore()
const slots = computed(() => lists.followed.map(c =>
useEmojiService.getEmojiCharacters(c.icon || '1f600').join('')))
const wantedIcon = computed(() =>
useEmojiService.getEmojiCharacters(props.collection?.icon || '1f600').join(''))
</script>
<template>
<PopModal :show="props.show" @close="emit('close')">
<div class="pop-modal__eyebrow">
<span class="pop-modal__eyebrow-dot"></span>
{{ slots.length }} of {{ FREE_COLLECTION_CAP }} collections
</div>
<Text tag="h3" class="pop-modal__title" :text="`Make room for ${props.collection.title}?`" />
<div class="pop-modal__content">
<div class="follow-limit__slots" aria-hidden="true">
<span v-for="icon in slots" class="follow-limit__slot jp-font">{{ icon }}</span>
<span class="follow-limit__plus">+</span>
<span class="follow-limit__slot follow-limit__slot--wanted jp-font">{{ wantedIcon }}</span>
</div>
<p class="pop-modal__description">
You're following {{ slots.length }} of {{ FREE_COLLECTION_CAP }} collections.
Swap one out, or go Pro and follow every collection you like.
</p>
</div>
<div class="pop-modal__footer">
<PrimaryButton variant="gray" size="md" label="Swap one out" @click="emit('manage')" />
<PrimaryButton variant="green" size="md" label="Go unlimited" @click="emit('go-pro')" />
</div>
</PopModal>
</template>
<!-- parent (Explore.vue) — replaces the shortcut branch: -->
<FollowLimitModal
v-model:show="modals.followLimit"
:collection="wantedCollection"
@manage="openManageCollections"
@go-pro="checkout"
/>
<style scoped>
/* FollowLimitModal — composition CSS. Slots visual: 5 filled tiles
(the user's followed collection icons, always jp-font) + a "+" +
one dashed WANTED tile holding the collection they just tapped.
All decorative — aria-hidden, deletable without reflow. */
.follow-limit .pop-modal__content {
gap: 18px;
}
.follow-limit__slots {
display: flex;
align-items: center;
justify-content: center;
gap: 7px;
}
.follow-limit__slot {
display: flex;
align-items: center;
justify-content: center;
width: 42px;
height: 42px;
border-radius: var(--r-md);
background: rgba(255, 255, 255, .06);
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, .10);
font-size: 2.1rem;
line-height: 1;
flex: none;
}
.follow-limit__plus {
margin: 0 1px;
font-family: var(--ff-dm-sans);
font-weight: 900;
font-size: 1.7rem;
line-height: 1;
color: color-mix(in srgb, var(--white) 45%, transparent);
flex: none;
}
/* the collection they just tapped — waiting on a slot, not locked out */
.follow-limit__slot--wanted {
background: color-mix(in srgb, var(--green-600) 10%, transparent);
box-shadow: none;
border: 1.5px dashed color-mix(in srgb, var(--green-600) 55%, transparent);
rotate: -6deg;
transition: rotate .2s;
animation: wanted-float 3.2s ease-in-out infinite;
}
.follow-limit:hover .follow-limit__slot--wanted { rotate: 0deg; }
@keyframes wanted-float {
0%, 100% { translate: 0 0; }
50% { translate: 0 -3px; }
}
@media (prefers-reduced-motion: reduce) {
.follow-limit__slot--wanted { animation: none; }
}
/* collection name inside the description reads at full ink */
.follow-limit__name {
color: var(--white);
font-weight: 700;
}
/* mobile — the mockup forces these via a `--m` modifier because a
375px stage frame can't trigger a real media query; in the app
they live in this ONE block */
@media (max-width: 800px) {
.follow-limit {
max-width: 100%;
padding: 26px 18px 18px;
}
.follow-limit .pop-modal__title {
font-size: 2.3rem;
}
.follow-limit .follow-limit__slot {
width: 37px;
height: 37px;
font-size: 1.85rem;
}
}
</style>
Free fits 5 collections. Swap one out to make room for Paw Pack, or go Pro for unlimited.
<FollowLimitModal> — new, on the existing PopModal shell<PaywallPrice> · catalog<PopModal> · <PrimaryButton> · <Text> · list store + useEmojiService<!-- ui/modals/FollowLimitModal.vue — NEW. Dark PopModal shell.
C = the card-A build + the founding price strip between content
and footer (identical facts + markup to the pro-required paywall
cards); full source below. The slots row is the user's REAL
followed collections: icons via
useEmojiService.getEmojiCharacters(collection.icon), same call
ListRow.vue makes. `collection` = the one they just tapped. -->
<script setup>
const props = defineProps(['show', 'collection']) // collection = the wanted one
const emit = defineEmits(['manage', 'go-pro', 'close'])
const lists = useListStore()
const slots = computed(() => lists.followed.map(c =>
useEmojiService.getEmojiCharacters(c.icon || '1f600').join('')))
const wantedIcon = computed(() =>
useEmojiService.getEmojiCharacters(props.collection?.icon || '1f600').join(''))
const offer = { was: '$32', now: '$16 / yr', tag: '50% off · founding' }
</script>
<template>
<PopModal :show="props.show" @close="emit('close')">
<div class="pop-modal__eyebrow">
<span class="pop-modal__eyebrow-dot"></span>
{{ slots.length }} of {{ FREE_COLLECTION_CAP }} collections
</div>
<Text tag="h3" class="pop-modal__title" text="Your collections are full" />
<div class="pop-modal__content">
<div class="follow-limit__slots" aria-hidden="true">
<span v-for="icon in slots" class="follow-limit__slot jp-font">{{ icon }}</span>
<span class="follow-limit__plus">+</span>
<span class="follow-limit__slot follow-limit__slot--wanted jp-font">{{ wantedIcon }}</span>
</div>
<p class="pop-modal__description">
Free fits {{ FREE_COLLECTION_CAP }} collections. Swap one out to make room
for <b class="follow-limit__name">{{ props.collection.title }}</b>,
or go Pro for unlimited.
</p>
</div>
<PaywallPrice :was="offer.was" :now="offer.now" :tag="offer.tag" />
<div class="pop-modal__footer">
<PrimaryButton variant="gray" size="md" label="Swap one out" @click="emit('manage')" />
<PrimaryButton variant="green" size="md" label="Go unlimited" @click="emit('go-pro')" />
</div>
</PopModal>
</template>
<!-- parent (Explore.vue) — replaces the shortcut branch: -->
<FollowLimitModal
v-model:show="modals.followLimit"
:collection="wantedCollection"
@manage="openManageCollections"
@go-pro="checkout"
/>
<style scoped>
/* FollowLimitModal — composition CSS. Slots visual: 5 filled tiles
(the user's followed collection icons, always jp-font) + a "+" +
one dashed WANTED tile holding the collection they just tapped.
All decorative — aria-hidden, deletable without reflow. */
.follow-limit .pop-modal__content {
gap: 18px;
}
.follow-limit__slots {
display: flex;
align-items: center;
justify-content: center;
gap: 7px;
}
.follow-limit__slot {
display: flex;
align-items: center;
justify-content: center;
width: 42px;
height: 42px;
border-radius: var(--r-md);
background: rgba(255, 255, 255, .06);
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, .10);
font-size: 2.1rem;
line-height: 1;
flex: none;
}
.follow-limit__plus {
margin: 0 1px;
font-family: var(--ff-dm-sans);
font-weight: 900;
font-size: 1.7rem;
line-height: 1;
color: color-mix(in srgb, var(--white) 45%, transparent);
flex: none;
}
/* the collection they just tapped — waiting on a slot, not locked out */
.follow-limit__slot--wanted {
background: color-mix(in srgb, var(--green-600) 10%, transparent);
box-shadow: none;
border: 1.5px dashed color-mix(in srgb, var(--green-600) 55%, transparent);
rotate: -6deg;
transition: rotate .2s;
animation: wanted-float 3.2s ease-in-out infinite;
}
.follow-limit:hover .follow-limit__slot--wanted { rotate: 0deg; }
@keyframes wanted-float {
0%, 100% { translate: 0 0; }
50% { translate: 0 -3px; }
}
@media (prefers-reduced-motion: reduce) {
.follow-limit__slot--wanted { animation: none; }
}
/* collection name inside the description reads at full ink */
.follow-limit__name {
color: var(--white);
font-weight: 700;
}
/* mobile — the mockup forces these via a `--m` modifier because a
375px stage frame can't trigger a real media query; in the app
they live in this ONE block */
@media (max-width: 800px) {
.follow-limit {
max-width: 100%;
padding: 26px 18px 18px;
}
.follow-limit .pop-modal__title {
font-size: 2.3rem;
}
.follow-limit .follow-limit__slot {
width: 37px;
height: 37px;
font-size: 1.85rem;
}
}
</style>
<style scoped>
/* PaywallPrice — same rules the pro-required paywall cards ship */
.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>
You're following 5 of 5 collections. Go Pro to follow as many as you like.
<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); this card adds its 14th trigger<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.' },
// -------- Added by this page (card D) -------------------
'follow-collection-max': { type: 'pro-required', image: PRO_IMG, title: 'Add more collections', description: 'You\'re following 5 of 5 collections. Go Pro to follow as many as you like.' },
};
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>
<!-- ui/pages/Explore.vue — the cap branch opens the paywall with the
new trigger; the trigger's config ships in TRIGGERS above. -->
<PaywallModal
v-model:show="modals.paywall"
trigger="follow-collection-max"
@go-pro="checkout"
/>
<style scoped>
.pop-modal__image {
width: 96px;
height: 96px;
object-fit: contain;
flex: none;
}
</style>
/* PaywallPrice.vue <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);
}
/* PopModalEyebrow.vue <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;
}
/* BulletRow.vue <style scoped> — bullets-layout triggers only
(sort-smart / create-list / save-combo) */
.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);
}
}
/* SVG check sits on the text's optical center */
.bullet-row .check-circle {
margin-top: 2px;
}
.bullet-row__text {
flex: 1;
}
/* CheckCircle.vue <style scoped> — composed by BulletRow; `--sm`
is the 16px bullet scale */
.check-circle {
flex: none;
width: 20px;
height: 20px;
color: var(--green-600);
}
.check-circle--sm {
width: 16px;
height: 16px;
}
Free fits 5 collections. Swap one out to make room for Paw Pack, or go Pro for unlimited.
<FollowLimitModal> — new, on the existing PopModal shell<PopModal> · <PrimaryButton> · <Text> · list store + useEmojiService<!-- ui/modals/FollowLimitModal.vue — NEW. Dark PopModal shell.
The slots row is the user's REAL followed collections: icons via
useEmojiService.getEmojiCharacters(collection.icon), same call
ListRow.vue makes. `collection` = the one they just tapped. -->
<script setup>
const props = defineProps(['show', 'collection']) // collection = the wanted one
const emit = defineEmits(['manage', 'go-pro', 'close'])
const lists = useListStore()
const slots = computed(() => lists.followed.map(c =>
useEmojiService.getEmojiCharacters(c.icon || '1f600').join('')))
const wantedIcon = computed(() =>
useEmojiService.getEmojiCharacters(props.collection?.icon || '1f600').join(''))
</script>
<template>
<PopModal :show="props.show" @close="emit('close')">
<div class="pop-modal__eyebrow">
<span class="pop-modal__eyebrow-dot"></span>
{{ slots.length }} of {{ FREE_COLLECTION_CAP }} collections
</div>
<Text tag="h3" class="pop-modal__title" text="Your collections are full" />
<div class="pop-modal__content">
<div class="follow-limit__slots" aria-hidden="true">
<span v-for="icon in slots" class="follow-limit__slot jp-font">{{ icon }}</span>
<span class="follow-limit__plus">+</span>
<span class="follow-limit__slot follow-limit__slot--wanted jp-font">{{ wantedIcon }}</span>
</div>
<p class="pop-modal__description">
Free fits {{ FREE_COLLECTION_CAP }} collections. Swap one out to make room
for <b class="follow-limit__name">{{ props.collection.title }}</b>,
or go Pro for unlimited.
</p>
</div>
<div class="pop-modal__footer">
<PrimaryButton variant="gray" size="md" label="Swap one out" @click="emit('manage')" />
<PrimaryButton variant="green" size="md" label="Go unlimited" @click="emit('go-pro')" />
</div>
</PopModal>
</template>
<!-- parent (Explore.vue) — replaces the shortcut branch: -->
<FollowLimitModal
v-model:show="modals.followLimit"
:collection="wantedCollection"
@manage="openManageCollections"
@go-pro="checkout"
/>
<style scoped>
/* FollowLimitModal — composition CSS. Slots visual: 5 filled tiles
(the user's followed collection icons, always jp-font) + a "+" +
one dashed WANTED tile holding the collection they just tapped.
All decorative — aria-hidden, deletable without reflow. */
.follow-limit .pop-modal__content {
gap: 18px;
}
.follow-limit__slots {
display: flex;
align-items: center;
justify-content: center;
gap: 7px;
}
.follow-limit__slot {
display: flex;
align-items: center;
justify-content: center;
width: 42px;
height: 42px;
border-radius: var(--r-md);
background: rgba(255, 255, 255, .06);
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, .10);
font-size: 2.1rem;
line-height: 1;
flex: none;
}
.follow-limit__plus {
margin: 0 1px;
font-family: var(--ff-dm-sans);
font-weight: 900;
font-size: 1.7rem;
line-height: 1;
color: color-mix(in srgb, var(--white) 45%, transparent);
flex: none;
}
/* the collection they just tapped — waiting on a slot, not locked out */
.follow-limit__slot--wanted {
background: color-mix(in srgb, var(--green-600) 10%, transparent);
box-shadow: none;
border: 1.5px dashed color-mix(in srgb, var(--green-600) 55%, transparent);
rotate: -6deg;
transition: rotate .2s;
animation: wanted-float 3.2s ease-in-out infinite;
}
.follow-limit:hover .follow-limit__slot--wanted { rotate: 0deg; }
@keyframes wanted-float {
0%, 100% { translate: 0 0; }
50% { translate: 0 -3px; }
}
@media (prefers-reduced-motion: reduce) {
.follow-limit__slot--wanted { animation: none; }
}
/* collection name inside the description reads at full ink */
.follow-limit__name {
color: var(--white);
font-weight: 700;
}
/* mobile — the mockup forces these via a `--m` modifier because a
375px stage frame can't trigger a real media query; in the app
they live in this ONE block */
@media (max-width: 800px) {
.follow-limit {
max-width: 100%;
padding: 26px 18px 18px;
}
.follow-limit .pop-modal__title {
font-size: 2.3rem;
}
.follow-limit .follow-limit__slot {
width: 37px;
height: 37px;
font-size: 1.85rem;
}
}
</style>