Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
4610c71
feat: layout creator payouts page files
tdgao Jun 9, 2026
7297070
feat: mock route responses
tdgao Jun 9, 2026
c047d2d
fix: styles
tdgao Jun 9, 2026
f7b056d
fix: more style improvements
tdgao Jun 9, 2026
94815c8
fix: distribute month card styles
tdgao Jun 9, 2026
d32619b
feat: fix empty value using text contrast
tdgao Jun 9, 2026
3cf489d
fix: verify payout modal styles
tdgao Jun 9, 2026
3fb087a
fix: creator payout initiated card
tdgao Jun 9, 2026
0a500ca
feat: implement expandable row for payouts table
tdgao Jun 9, 2026
6b111da
feat: add payouts table expand/collapse transition
tdgao Jun 9, 2026
9e5de0f
feat: add creator payouts page to admin pages dropdown
tdgao Jun 9, 2026
494df86
fix: improve breakdown distribution card styles
tdgao Jun 9, 2026
91fe2b8
feat: add confirm modal for deleting adjustments
tdgao Jun 9, 2026
76ff596
Merge branch 'main' into truman/creator-payouts-frontend
tdgao Jun 12, 2026
0a9f4a2
feat: add table tooltips
tdgao Jun 12, 2026
ad420a1
chore: update mock data dates
tdgao Jun 12, 2026
b6e76f7
feat: polish styles
tdgao Jun 12, 2026
ab0a3a6
feat: fix variance deduction line in distribution breakdown
tdgao Jun 12, 2026
8ed8596
feat-small: dash to solid
tdgao Jun 12, 2026
0fd1824
Merge branch 'main' into truman/creator-payouts-frontend
tdgao Jun 12, 2026
3f8890e
fix: small button to expand table
tdgao Jun 12, 2026
3f5eb62
feat: implement tooltip on badges
tdgao Jun 12, 2026
7f6e40d
pnpm prepr
tdgao Jun 12, 2026
0fdb251
fix: ci
tdgao Jun 12, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
<template>
<ConfirmModal
ref="deleteAdjustmentModal"
title="Delete adjustment?"
:description="deleteAdjustmentDescription"
proceed-label="Delete"
:markdown="false"
width="36rem"
@proceed="confirmRemoveAdjustment"
/>

<div
class="flex flex-col gap-5 rounded-2xl border border-solid border-surface-4 bg-surface-3 p-6"
>
<h2 class="m-0 text-lg font-semibold text-contrast">Adjustments</h2>

<div v-if="adjustments.length > 0" class="flex flex-col gap-2.5">
<div
v-for="(adjustment, index) in adjustments"
:key="index"
class="grid grid-cols-[minmax(0,1fr)_6.5rem_auto] gap-1.5"
>
<StyledInput
:model-value="adjustment.description"
placeholder="Description"
autocomplete="off"
wrapper-class="w-full"
@update:model-value="updateAdjustment(index, 'description', String($event ?? ''))"
/>
<StyledInput
:model-value="adjustment.amount"
type="number"
inputmode="decimal"
placeholder="0.00"
:step="0.01"
wrapper-class="w-full"
@update:model-value="updateAdjustment(index, 'amount', Number($event ?? 0))"
/>
<ButtonStyled circular type="outlined" color="red">
<button
:aria-label="`Remove adjustment ${index + 1}`"
@click="openDeleteAdjustmentModal(index)"
>
<TrashIcon aria-hidden="true" />
</button>
</ButtonStyled>
</div>
</div>

<ButtonStyled type="outlined">
<button class="w-full" @click="addAdjustment">
<PlusIcon aria-hidden="true" />
Add Adjustment
</button>
</ButtonStyled>
</div>
</template>

<script setup lang="ts">
import { PlusIcon, TrashIcon } from '@modrinth/assets'
import { ButtonStyled, ConfirmModal, StyledInput } from '@modrinth/ui'
import { computed, ref } from 'vue'

import { type DistributionAdjustment,formatCurrency } from '../utils'

const adjustments = defineModel<DistributionAdjustment[]>({ required: true })
const pendingDeleteIndex = ref<number | null>(null)
const deleteAdjustmentModal = ref<InstanceType<typeof ConfirmModal> | null>(null)

const pendingDeleteAdjustment = computed(() =>
pendingDeleteIndex.value === null ? null : adjustments.value[pendingDeleteIndex.value],
)
const deleteAdjustmentDescription = computed(() => {
const adjustment = pendingDeleteAdjustment.value

if (!adjustment) {
return ''
}

if (!adjustment.description) {
return `Delete adjustment for ${formatAdjustmentAmount(adjustment.amount)}`
}

return `Delete adjustment "${adjustment.description}" for ${formatAdjustmentAmount(adjustment.amount)}`
})

function addAdjustment() {
adjustments.value = [...adjustments.value, { description: '', amount: 0 }]
}

function openDeleteAdjustmentModal(index: number) {
pendingDeleteIndex.value = index
deleteAdjustmentModal.value?.show()
}

function confirmRemoveAdjustment() {
if (pendingDeleteIndex.value === null) {
return
}

adjustments.value = adjustments.value.filter(
(_, adjustmentIndex) => adjustmentIndex !== pendingDeleteIndex.value,
)
pendingDeleteIndex.value = null
}

function updateAdjustment(
index: number,
field: keyof DistributionAdjustment,
value: string | number,
) {
adjustments.value = adjustments.value.map((adjustment, adjustmentIndex) =>
adjustmentIndex === index ? { ...adjustment, [field]: value } : adjustment,
)
}

function formatAdjustmentAmount(amount: number): string {
const formatted = formatCurrency(Math.abs(amount), { cents: true })
return amount < 0 ? `-${formatted}` : formatted
}
</script>
Original file line number Diff line number Diff line change
@@ -0,0 +1,160 @@
<template>
<div class="rounded-2xl border border-solid border-surface-4 bg-surface-3 p-6">
<h2 class="m-0 text-lg font-semibold text-contrast">Distribution Breakdown</h2>

<div class="mt-5 border-0 border-t border-solid border-surface-4 pt-5">
<DistributeBreakdownRow
label="Estimated Revenue"
:value="formatCurrency(estimatedRevenue, { cents: true })"
/>
<DistributeBreakdownRow
label="Clean.io Fee"
:value="formatSignedCurrencyWithCents(-Math.abs(payout.fees_deducted_usd))"
:tone="getAmountTone(-Math.abs(payout.fees_deducted_usd))"
/>
<DistributeBreakdownRow
label="Variance Deduction"
:value="formatSignedCurrencyWithCents(payout.variance_adjustment_usd)"
:tone="getAmountTone(payout.variance_adjustment_usd)"
/>
</div>

<div class="mt-4 border-0 border-t border-solid border-surface-4 pt-4">
<DistributeBreakdownRow
label="Net Estimated Revenue"
:value="formatCurrency(payout.net_estimated_revenue_usd, { cents: true })"
strong
/>
</div>

<div class="mt-4 border-0 border-t border-solid border-surface-4 pt-4">
<DistributeBreakdownRow label="Actual Revenue" :value="actualRevenueLabel" />
<DistributeBreakdownRow
label="Variance Resolution"
:value="varianceResolutionLabel"
:description="varianceResolutionDescription"
:tone="getAmountTone(varianceResolution)"
/>
<DistributeBreakdownRow
v-for="(adjustment, index) in adjustments"
:key="`${index}-${adjustment.description}`"
:label="adjustment.description"
:value="formatSignedCurrencyWithCents(adjustment.amount)"
:tone="getAmountTone(adjustment.amount)"
/>
</div>

<div class="mt-4 border-0 border-t border-solid border-surface-4 pt-4">
<DistributeBreakdownRow label="Net Actual Revenue" :value="netActualLabel" strong />
</div>

<div class="mt-4 border-0 border-t border-solid border-surface-4 pt-4">
<DistributeBreakdownRow label="Creator Revenue (75%)" :value="creatorRevenueLabel" />
<DistributeBreakdownRow label="Modrinth Revenue (25%)" :value="modrinthRevenueLabel" />
</div>
</div>
</template>

<script setup lang="ts">
import type { Labrinth } from '@modrinth/api-client'
import { computed } from 'vue'

import {
type DistributionAdjustment,
formatCurrency,
getCreatorShare,
getModrinthShare,
getNetActualRevenue,
roundCurrency,
} from '../utils'
import DistributeBreakdownRow from './DistributeBreakdownRow.vue'

const props = defineProps<{
payout: Labrinth.Payouts.Internal.HistoryItem
amountReceived: number | undefined
adjustments: DistributionAdjustment[]
}>()

const emptyValue = '—'

const estimatedRevenue = computed(() =>
props.payout.days.reduce((total, day) => total + (day.estimated_revenue_usd ?? 0), 0),
)
const hasActualAmount = computed(() => (props.amountReceived ?? 0) > 0)
const actualRevenue = computed(() => props.amountReceived ?? 0)
const netActualRevenue = computed(() => getNetActualRevenue(actualRevenue.value, props.adjustments))
const varianceResolution = computed(() =>
roundCurrency(actualRevenue.value - props.payout.net_estimated_revenue_usd),
)
const varianceDeduction = computed(() => Math.abs(props.payout.variance_adjustment_usd))
const returnedVariance = computed(() =>
Math.min(Math.max(varianceResolution.value, 0), varianceDeduction.value),
)
const actualRevenueLabel = computed(() =>
hasActualAmount.value ? formatCurrency(actualRevenue.value, { cents: true }) : emptyValue,
)
const varianceResolutionLabel = computed(() =>
hasActualAmount.value ? formatSignedCurrencyWithCents(varianceResolution.value) : emptyValue,
)
const varianceResolutionDescription = computed(() => {
if (!hasActualAmount.value) {
return undefined
}

if (varianceResolution.value < 0) {
return `Variance consumed + ${formatCurrency(Math.abs(varianceResolution.value), { cents: true })} additional shortfall`
}

if (varianceDeduction.value === 0) {
return undefined
}

if (varianceResolution.value >= varianceDeduction.value) {
return 'Variance deduction fully returned'
}

return `${formatCurrency(returnedVariance.value, { cents: true })} of ${formatCurrency(varianceDeduction.value, { cents: true })} returned`
})
const adjustments = computed(() =>
props.adjustments.filter((adjustment) => adjustment.description || adjustment.amount !== 0),
)
const netActualLabel = computed(() =>
hasActualAmount.value ? formatCurrency(netActualRevenue.value, { cents: true }) : emptyValue,
)
const creatorRevenueLabel = computed(() =>
hasActualAmount.value
? formatCurrency(getCreatorShare(netActualRevenue.value), { cents: true })
: emptyValue,
)
const modrinthRevenueLabel = computed(() =>
hasActualAmount.value
? formatCurrency(getModrinthShare(netActualRevenue.value), { cents: true })
: emptyValue,
)

function formatSignedCurrencyWithCents(amount: number): string {
const formatted = formatCurrency(Math.abs(amount), { cents: true })

if (amount < 0) {
return `-${formatted}`
}

if (amount > 0) {
return `+${formatted}`
}

return formatted
}

function getAmountTone(amount: number): 'positive' | 'negative' | 'neutral' {
if (amount > 0) {
return 'positive'
}

if (amount < 0) {
return 'negative'
}

return 'neutral'
}
</script>
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
<template>
<div class="mb-4 flex items-start justify-between gap-6 last:mb-0">
<div class="flex min-w-0 flex-col gap-1">
<span
class="font-base text-base"
:class="strong ? 'font-medium text-contrast' : 'text-primary'"
>
{{ label }}
</span>
<span v-if="description" class="text-sm text-secondary">
{{ description }}
</span>
</div>
<span
class="font-base shrink-0 text-right text-base"
:class="[
value === emptyValue
? 'text-primary opacity-60'
: strong
? 'text-lg font-medium text-contrast'
: valueToneClass,
]"
>
{{ value }}
</span>
</div>
</template>

<script setup lang="ts">
import { computed } from 'vue'

const props = defineProps<{
label: string
value: string
description?: string
negative?: boolean
strong?: boolean
tone?: 'positive' | 'negative' | 'neutral'
}>()

const emptyValue = '—'
const valueToneClass = computed(() => {
if (props.tone === 'positive') {
return 'text-green'
}

if (props.tone === 'negative' || props.negative) {
return 'text-red'
}

return 'text-primary'
})
</script>
Loading
Loading