Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
3 changes: 3 additions & 0 deletions CHANGELOG.en.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,9 @@ The English translation covers the latest versions only; the full history (in Ru

## Unreleased

- [phone] Invite: a balance credit shows the amount
- [tech] Referral variants: balance percent, tiers, friend discount and retention. Off by default. Cancelling a journal row takes back days and balance. Migration 013

## 0.30.1-beta.6

- [phone] The embedded 2160 Player is updated to 0.2.26. The seek bar is full width with equal side gutters, elapsed time on the left and remaining on the right (both show --:-- until duration is known), and buffering shows a spinner in the centre button
Expand Down
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,9 @@

## Не выпущено

- [phone] Приглашение: если рефереру начислен баланс, экран показывает сумму
- [tech] Рефералка: процент на баланс, уровни, скидка другу и удержание. По умолчанию выключены. Отмена журнала снимает дни и баланс. Миграция 013

## 0.30.1-beta.6

- [phone] Встроенный плеер 2160 обновлён до 0.2.26. Полоса перемотки на всю ширину с одинаковыми боковыми отступами, прошедшее слева и остаток справа (пока длина неизвестна, обе стороны --:--), при буферизации в центре спиннер
Expand Down
5 changes: 3 additions & 2 deletions mobile/src/lib/referralLoad.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,17 +8,18 @@ export interface PhoneReferral {
code: string;
friends: number;
noticeDays: number;
noticeCredit: number;
}

const OFF: PhoneReferral = { enabled: false, code: '', friends: 0, noticeDays: 0 };
const OFF: PhoneReferral = { enabled: false, code: '', friends: 0, noticeDays: 0, noticeCredit: 0 };

export function loadPhoneReferral(): Promise<PhoneReferral> {
if (!billingSession()) return Promise.resolve(OFF);
return fetchBillingAccount(phoneSourceContext()).then(
(acc) => {
const row = acc && acc.referral;
if (!row || !row.enabled) return OFF;
return { enabled: true, code: row.code || '', friends: row.friends || 0, noticeDays: row.noticeDays || 0 };
return { enabled: true, code: row.code || '', friends: row.friends || 0, noticeDays: row.noticeDays || 0, noticeCredit: row.noticeCredit || 0 };
},
() => OFF,
);
Expand Down
2 changes: 2 additions & 0 deletions mobile/src/screens/Invite.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ export interface InviteCard {
code: string;
friends: number;
noticeDays?: number;
noticeCredit?: number;
}

/** The program is off until an admin turns it on. A loader reads the signed-in card from /v1/me. */
Expand Down Expand Up @@ -87,6 +88,7 @@ export function Invite({
<p data-invite-code>{t('invite.code', { code: view.code })}</p>
<p data-invite-count>{t('invite.friends', { n: view.friends })}</p>
{card.noticeDays ? <p data-invite-notice>{t('invite.noticeApp', { n: card.noticeDays })}</p> : null}
{card.noticeCredit ? <p data-invite-credit>{t('invite.noticeCredit', { n: card.noticeCredit })}</p> : null}
{qr ? <img src={qr} alt={t('invite.qr')} width={180} height={180} /> : null}
<button type="button" class="m-btn m-btn-primary tm" data-code="P36.2" data-invite-share onClick={share}>
{t('invite.share')}
Expand Down
2 changes: 1 addition & 1 deletion mobile/tests/invite.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ beforeEach(() => {

describe('tell friends', () => {
it('stays off when the phone is signed out', async () => {
expect(await loadPhoneReferral()).toEqual({ enabled: false, code: '', friends: 0, noticeDays: 0 });
expect(await loadPhoneReferral()).toEqual({ enabled: false, code: '', friends: 0, noticeDays: 0, noticeCredit: 0 });
});

it('says the program is off and hides the code', () => {
Expand Down
7 changes: 7 additions & 0 deletions ops/billing/admin/src/lib/nextTierFriends.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
/** Smallest friend-count that is not already used, so a new tier does not replace a saved one. */
export function nextTierFriends(friends: number[]): number {
const used = new Set(friends);
let n = 1;
while (used.has(n) && n < 10000) n += 1;
return n;
}
11 changes: 10 additions & 1 deletion ops/billing/admin/src/lib/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -194,15 +194,24 @@ export type HomeData = {
enabled?: boolean;
referrerDays?: number;
friendDays?: number;
balance?: boolean;
balancePercent?: number;
balanceMax?: number;
balanceMonthlyCap?: number;
tiers?: boolean;
tierList?: { friends: number; days: number; percent: number }[];
friendDiscount?: boolean;
friendDiscountPercent?: number;
friendDiscountRub?: number;
friendDiscountDays?: number;
retention?: boolean;
retentionMonths?: number;
retentionDays?: number;
delayDays?: number;
yearlyCap?: number;
};
};
referralJournal?: { id: string; friendId?: string; status: string; days: number; reason?: string }[];
referralJournal?: { id: string; friendId?: string; status: string; days: number; reason?: string; variant?: string; credit?: number; shortfall?: string }[];
donations?: {
rows?: { id: string; atText: string; who: string; telegramId: string; amountRub: number; method: string; comment: string; commentHidden: boolean; nameHidden: boolean; status: string }[];
summary?: { day: { sum: number; count: number; average: number }; week: { sum: number; count: number; average: number }; month: { sum: number; count: number; average: number } };
Expand Down
139 changes: 124 additions & 15 deletions ops/billing/admin/src/panels/ReferralPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,23 +4,56 @@ import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { post, type PostResult } from '@/lib/api';
import { nextTierFriends } from '@/lib/nextTierFriends';

type Tier = { friends: number; days: number; percent: number };

type Settings = {
enabled?: boolean;
referrerDays?: number;
friendDays?: number;
balance?: boolean;
balancePercent?: number;
balanceMax?: number;
balanceMonthlyCap?: number;
tiers?: boolean;
tierList?: Tier[];
friendDiscount?: boolean;
friendDiscountPercent?: number;
friendDiscountRub?: number;
friendDiscountDays?: number;
retention?: boolean;
retentionMonths?: number;
retentionDays?: number;
delayDays?: number;
yearlyCap?: number;
};

type Row = { id: string; friendId?: string; status: string; days: number; reason?: string };
type Row = {
id: string;
friendId?: string;
status: string;
days: number;
variant?: string;
credit?: number;
reason?: string;
shortfall?: string;
};

function clamp(raw: string, max: number) {
const n = Math.floor(Number(raw));
if (!Number.isFinite(n) || n < 0) return 0;
return Math.min(max, n);
}

function variantLabel(variant?: string) {
if (variant === 'balance') return 'Баланс';
if (variant === 'retention') return 'Удержание';
return 'Дни';
}

/**
* Six switches, each with its own number. The master flag is separate and starts off.
* Each variant is its own switch and its own numbers. The master flag is separate.
* Local copy is what the next save sends, so a second edit does not restore the old flag.
*/
export function ReferralPanel({
Expand All @@ -38,6 +71,7 @@ export function ReferralPanel({
const [rows, setRows] = useState<Row[]>(journal || []);
const [busy, setBusy] = useState(false);
const [error, setError] = useState('');
const [note, setNote] = useState('');
useEffect(() => {
setLocal(settings || {});
}, [settings]);
Expand All @@ -49,6 +83,7 @@ export function ReferralPanel({
const next = { ...local, ...patch };
setBusy(true);
setError('');
setNote('');
try {
const body = { settings: next, ...(cancelId ? { cancelId } : {}) };
const res = run ? await run(body) : await post('/admin/referral', csrf, body);
Expand All @@ -57,6 +92,7 @@ export function ReferralPanel({
return;
}
setLocal(next);
if (res.flash?.text) setNote(res.flash.text);
if (cancelId) {
setRows((cur) => cur.map((row) => (row.id === cancelId ? { ...row, status: 'cancelled', reason: 'manual' } : row)));
}
Expand All @@ -67,30 +103,103 @@ export function ReferralPanel({
}
};
const toggle = (key: keyof Settings) => void save({ [key]: !local[key] } as Settings);
const tiers = local.tierList || [];
const saveTiers = (tierList: Tier[]) => void save({ tierList });
return (
<Card>
<CardHeader>
<CardTitle>Реферальная программа</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
<CardContent className="space-y-3 [&_label]:mt-1 [&_label]:block">
{error ? <p className="text-sm text-destructive">{error}</p> : null}
{note ? <p className="text-sm">{note}</p> : null}
<Button type="button" variant={local.enabled ? 'default' : 'secondary'} disabled={busy} onClick={() => toggle('enabled')}>
{local.enabled ? 'Программа включена' : 'Программа выключена'}
</Button>
<Label>Дни рефереру (макс. 14)</Label>
<Input defaultValue={String(local.referrerDays ?? 0)} onBlur={(e) => void save({ referrerDays: Number(e.target.value) })} />
<Label>Бонусные дни другу (макс. 14)</Label>
<Input defaultValue={String(local.friendDays ?? 0)} onBlur={(e) => void save({ friendDays: Number(e.target.value) })} />
<Label>Процент на баланс</Label>
<Input defaultValue={String(local.balancePercent ?? 0)} onBlur={(e) => void save({ balancePercent: Number(e.target.value) })} />
<Button type="button" variant={local.tiers ? 'default' : 'secondary'} disabled={busy} onClick={() => toggle('tiers')}>Уровни 3/5/10</Button>
<Button type="button" variant={local.friendDiscount ? 'default' : 'secondary'} disabled={busy} onClick={() => toggle('friendDiscount')}>Скидка другу</Button>
<Button type="button" variant={local.retention ? 'default' : 'secondary'} disabled={busy} onClick={() => toggle('retention')}>Бонус удержания</Button>
<p className="text-sm text-muted-foreground">Начисление через {local.delayDays ?? 7} дн. после оплаты. Годовой лимит {local.yearlyCap ?? 12} мес. Только при активной подписке реферера и оплате друга.</p>
<Label>Дни рефереру (0-14)</Label>
<Input defaultValue={String(local.referrerDays ?? 0)} onBlur={(e) => void save({ referrerDays: clamp(e.target.value, 14) })} />
<p className="text-sm text-muted-foreground">Базовые дни за первого оплатившего друга. Потолок одного начисления - 14 дней.</p>
<Label>Бонусные дни другу (0-14)</Label>
<Input defaultValue={String(local.friendDays ?? 0)} onBlur={(e) => void save({ friendDays: clamp(e.target.value, 14) })} />
<Label>Задержка, дней (0-365)</Label>
<Input defaultValue={String(local.delayDays ?? 7)} onBlur={(e) => void save({ delayDays: clamp(e.target.value, 365) })} />
<p className="text-sm text-muted-foreground">Дни, баланс и удержание начисляются только после этой паузы. 0 - сразу после оплаты.</p>
<Label>Годовой лимит, месяцев (0-120)</Label>
<Input defaultValue={String(local.yearlyCap ?? 12)} onBlur={(e) => void save({ yearlyCap: clamp(e.target.value, 120) })} />
<p className="text-sm text-muted-foreground">Сколько месяцев бонусных дней реферер может получить за 365 дней. 0 - без лимита. Месяц считается как 30 дней.</p>

<Button type="button" variant={local.balance ? 'default' : 'secondary'} disabled={busy} onClick={() => toggle('balance')}>
{local.balance ? 'Процент на баланс включен' : 'Процент на баланс выключен'}
</Button>
<Label>Процент от оплаты друга (0-100)</Label>
<Input defaultValue={String(local.balancePercent ?? 0)} onBlur={(e) => void save({ balancePercent: clamp(e.target.value, 100) })} />
<Label>Максимум за одну оплату, руб (0 - без потолка)</Label>
<Input defaultValue={String(local.balanceMax ?? 0)} onBlur={(e) => void save({ balanceMax: clamp(e.target.value, 1000000) })} />
<Label>Потолок баланса за календарный месяц, руб (0 - без потолка)</Label>
<Input defaultValue={String(local.balanceMonthlyCap ?? 0)} onBlur={(e) => void save({ balanceMonthlyCap: clamp(e.target.value, 1000000) })} />
<p className="text-sm text-muted-foreground">Рубли падают на внутренний баланс подписки и списываются при следующей оплате. Пока переключатель выключен, процент не начисляется. Уровень может поднять процент, если он выше.</p>

<Button type="button" variant={local.tiers ? 'default' : 'secondary'} disabled={busy} onClick={() => toggle('tiers')}>
{local.tiers ? 'Уровни включены' : 'Уровни выключены'}
</Button>
<p className="text-sm text-muted-foreground">Порог - сколько друзей уже оплатили. Дни уровня заменяют базовые, если они больше, но не больше 14. Процент уровня берется, если он выше процента на баланс. Пустой список ничего не меняет.</p>
{local.tiers ? (
<div className="space-y-2">
{tiers.map((tier, index) => (
<div key={tier.friends + '-' + index} className="flex flex-wrap items-center gap-2">
<Input className="w-24" defaultValue={String(tier.friends)} aria-label="Друзей" onBlur={(e) => {
const next = tiers.slice();
next[index] = { ...tier, friends: clamp(e.target.value, 10000) };
saveTiers(next);
}} />
<Input className="w-24" defaultValue={String(tier.days)} aria-label="Дней уровня" onBlur={(e) => {
const next = tiers.slice();
next[index] = { ...tier, days: clamp(e.target.value, 14) };
saveTiers(next);
}} />
<Input className="w-24" defaultValue={String(tier.percent)} aria-label="Процент уровня" onBlur={(e) => {
const next = tiers.slice();
next[index] = { ...tier, percent: clamp(e.target.value, 100) };
saveTiers(next);
}} />
<Button type="button" variant="secondary" disabled={busy} onClick={() => saveTiers(tiers.filter((_, i) => i !== index))}>Убрать</Button>
</div>
))}
<Button type="button" variant="secondary" disabled={busy} onClick={() => saveTiers(tiers.concat([{ friends: nextTierFriends(tiers.map((tier) => tier.friends)), days: 14, percent: 0 }]))}>
Добавить уровень
</Button>
</div>
) : null}

<Button type="button" variant={local.friendDiscount ? 'default' : 'secondary'} disabled={busy} onClick={() => toggle('friendDiscount')}>
{local.friendDiscount ? 'Скидка другу включена' : 'Скидка другу выключена'}
</Button>
<Label>Скидка, процент (0-100)</Label>
<Input defaultValue={String(local.friendDiscountPercent ?? 0)} onBlur={(e) => void save({ friendDiscountPercent: clamp(e.target.value, 100) })} />
<Label>Скидка, руб (0 - нет фиксированной суммы)</Label>
<Input defaultValue={String(local.friendDiscountRub ?? 0)} onBlur={(e) => void save({ friendDiscountRub: clamp(e.target.value, 1000000) })} />
<Label>Срок скидки, дней с привязки (0 - без срока)</Label>
<Input defaultValue={String(local.friendDiscountDays ?? 0)} onBlur={(e) => void save({ friendDiscountDays: clamp(e.target.value, 3650) })} />
<p className="text-sm text-muted-foreground">Только первая оплата подписки, и только пока у реферера есть доступ. Процент и рубли складываются. Цена в боте и в приложении берется уже со скидкой и замораживается в заказе.</p>

<Button type="button" variant={local.retention ? 'default' : 'secondary'} disabled={busy} onClick={() => toggle('retention')}>
{local.retention ? 'Удержание включено' : 'Удержание выключено'}
</Button>
<Label>Подряд месяцев (1-36)</Label>
<Input defaultValue={String(local.retentionMonths ?? 3)} onBlur={(e) => void save({ retentionMonths: Math.max(1, clamp(e.target.value, 36)) })} />
<Label>Дней за удержание (0-14)</Label>
<Input defaultValue={String(local.retentionDays ?? 0)} onBlur={(e) => void save({ retentionDays: clamp(e.target.value, 14) })} />
<p className="text-sm text-muted-foreground">Месяц - следующая оплата через 27-45 дней. Большой разрыв начинает счет заново. 0 дней - бонус не выдается, даже если переключатель включен.</p>

<p className="text-sm text-muted-foreground">Бонус только при активной подписке реферера и оплате друга. Нельзя пригласить себя. Один Telegram или одно устройство - одно приглашение.</p>
<h3 className="font-semibold">Журнал</h3>
{rows.length === 0 ? <p className="text-sm">Начислений нет</p> : rows.map((row) => (
<div key={row.id} className="flex items-center gap-2 text-sm">
<span>{row.friendId || row.id} · {row.status} · {row.days} дн.</span>
<div key={row.id} className="flex flex-wrap items-center gap-2 text-sm">
<span>
{row.friendId || row.id} · {variantLabel(row.variant)} · {row.status} · {row.days} дн.
{row.credit ? ' · ' + row.credit + ' ₽' : ''}
{row.shortfall ? ' · ' + row.shortfall : ''}
</span>
{row.status !== 'cancelled' ? (
<Button type="button" variant="secondary" disabled={busy} onClick={() => void save({}, row.id)}>
Отменить
Expand Down
Loading
Loading