Add message board and bonus program preview
This commit is contained in:
34
app/app.vue
34
app/app.vue
@@ -5,9 +5,10 @@ import { hasManagerAccess } from '~/utils/roles';
|
|||||||
|
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
const isLoginPage = computed(() => route.path === '/login');
|
const isLoginPage = computed(() => route.path === '/login');
|
||||||
|
const isBonusProgramPage = computed(() => route.path === '/bonus-program');
|
||||||
const meQuery = useQuery(MeDocument);
|
const meQuery = useQuery(MeDocument);
|
||||||
const hasManagerDock = computed(() => (
|
const hasManagerDock = computed(() => (
|
||||||
!isLoginPage.value && hasManagerAccess(meQuery.result.value?.me?.role)
|
!isLoginPage.value && !isBonusProgramPage.value && hasManagerAccess(meQuery.result.value?.me?.role)
|
||||||
));
|
));
|
||||||
|
|
||||||
const managerPageTabs = computed(() => {
|
const managerPageTabs = computed(() => {
|
||||||
@@ -48,7 +49,7 @@ const managerPageTabs = computed(() => {
|
|||||||
{
|
{
|
||||||
key: 'balances',
|
key: 'balances',
|
||||||
label: 'Балансы',
|
label: 'Балансы',
|
||||||
active: route.query.tab !== 'withdrawals',
|
active: route.query.tab !== 'withdrawals' && route.query.tab !== 'manager',
|
||||||
to: {
|
to: {
|
||||||
path: '/bonus-system',
|
path: '/bonus-system',
|
||||||
query: {
|
query: {
|
||||||
@@ -69,6 +70,18 @@ const managerPageTabs = computed(() => {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
key: 'manager',
|
||||||
|
label: 'Менеджеру',
|
||||||
|
active: route.query.tab === 'manager',
|
||||||
|
to: {
|
||||||
|
path: '/bonus-system',
|
||||||
|
query: {
|
||||||
|
...route.query,
|
||||||
|
tab: 'manager',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -77,13 +90,14 @@ const managerPageTabs = computed(() => {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="lk-shell" data-theme="aqua">
|
<div :class="isBonusProgramPage ? 'bonus-program-shell' : 'lk-shell'" data-theme="aqua">
|
||||||
<UiAppHeader v-if="!isLoginPage" />
|
<UiAppHeader v-if="!isLoginPage && !isBonusProgramPage" />
|
||||||
<main
|
<main
|
||||||
class="mx-auto w-full max-w-[1440px] p-4 pt-[104px] md:p-6 md:pt-[112px] lg:p-8 lg:pt-[118px]"
|
:class="isBonusProgramPage
|
||||||
:class="hasManagerDock ? 'pb-[116px] md:pb-[128px]' : ''"
|
? 'bonus-program-main'
|
||||||
|
: ['mx-auto w-full max-w-[1440px] p-4 pt-[104px] md:p-6 md:pt-[112px] lg:p-8 lg:pt-[118px]', hasManagerDock ? 'pb-[116px] md:pb-[128px]' : '']"
|
||||||
>
|
>
|
||||||
<div v-if="managerPageTabs.length" class="lk-page-tabs-shell">
|
<div v-if="managerPageTabs.length && !isBonusProgramPage" class="lk-page-tabs-shell">
|
||||||
<nav class="manager-page-tabs" aria-label="Разделы страницы">
|
<nav class="manager-page-tabs" aria-label="Разделы страницы">
|
||||||
<NuxtLink
|
<NuxtLink
|
||||||
v-for="tab in managerPageTabs"
|
v-for="tab in managerPageTabs"
|
||||||
@@ -97,7 +111,11 @@ const managerPageTabs = computed(() => {
|
|||||||
</nav>
|
</nav>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="lk-content-canvas" :class="{ 'lk-content-canvas--with-tabs': managerPageTabs.length }">
|
<div
|
||||||
|
:class="isBonusProgramPage
|
||||||
|
? 'bonus-program-stage'
|
||||||
|
: ['lk-content-canvas', { 'lk-content-canvas--with-tabs': managerPageTabs.length }]"
|
||||||
|
>
|
||||||
<NuxtPage />
|
<NuxtPage />
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
|
|||||||
@@ -12,6 +12,12 @@
|
|||||||
--brand-muted: #d8eee1;
|
--brand-muted: #d8eee1;
|
||||||
--brand-ink: #0f2f20;
|
--brand-ink: #0f2f20;
|
||||||
--lk-canvas-bg: color-mix(in oklab, #edf3ef 82%, white);
|
--lk-canvas-bg: color-mix(in oklab, #edf3ef 82%, white);
|
||||||
|
--bonus-surface: #08090b;
|
||||||
|
--bonus-surface-raised: #101218;
|
||||||
|
--bonus-border: rgba(255, 255, 255, 0.08);
|
||||||
|
--bonus-text: rgba(255, 255, 255, 0.94);
|
||||||
|
--bonus-muted: rgba(255, 255, 255, 0.6);
|
||||||
|
--bonus-accent: #d8ff3e;
|
||||||
}
|
}
|
||||||
|
|
||||||
[data-theme='aqua'] {
|
[data-theme='aqua'] {
|
||||||
@@ -70,6 +76,17 @@ body {
|
|||||||
isolation: isolate;
|
isolation: isolate;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.bonus-program-shell {
|
||||||
|
min-height: 100vh;
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
background:
|
||||||
|
radial-gradient(circle at 12% 16%, rgba(216, 255, 62, 0.12) 0%, rgba(216, 255, 62, 0) 28%),
|
||||||
|
radial-gradient(circle at 86% 14%, rgba(76, 113, 255, 0.15) 0%, rgba(76, 113, 255, 0) 26%),
|
||||||
|
linear-gradient(180deg, #07080a 0%, #0a0c10 42%, #050507 100%);
|
||||||
|
color: var(--bonus-text);
|
||||||
|
}
|
||||||
|
|
||||||
.lk-shell::before,
|
.lk-shell::before,
|
||||||
.lk-shell::after {
|
.lk-shell::after {
|
||||||
content: '';
|
content: '';
|
||||||
@@ -244,6 +261,200 @@ body {
|
|||||||
color: #557562;
|
color: #557562;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.bonus-program-main {
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 1480px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: clamp(1.25rem, 2vw, 2rem);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bonus-program-stage {
|
||||||
|
min-height: calc(100vh - 2rem);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bonus-program-page {
|
||||||
|
position: relative;
|
||||||
|
min-height: calc(100vh - 4rem);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bonus-program-hero {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1rem;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: clamp(1rem, 2vw, 1.5rem) 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bonus-program-kicker {
|
||||||
|
font-size: 0.72rem;
|
||||||
|
font-weight: 800;
|
||||||
|
letter-spacing: 0.24em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: rgba(255, 255, 255, 0.56);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bonus-program-title {
|
||||||
|
max-width: 16ch;
|
||||||
|
font-size: clamp(2.5rem, 5vw, 5.25rem);
|
||||||
|
line-height: 0.94;
|
||||||
|
font-weight: 800;
|
||||||
|
letter-spacing: -0.06em;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bonus-program-copy {
|
||||||
|
max-width: 44rem;
|
||||||
|
font-size: 1rem;
|
||||||
|
line-height: 1.75;
|
||||||
|
color: var(--bonus-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bonus-program-panel {
|
||||||
|
border: 1px solid var(--bonus-border);
|
||||||
|
border-radius: 2rem;
|
||||||
|
background:
|
||||||
|
linear-gradient(180deg, rgba(255, 255, 255, 0.035), rgba(255, 255, 255, 0.02)),
|
||||||
|
var(--bonus-surface-raised);
|
||||||
|
box-shadow:
|
||||||
|
0 24px 60px rgba(0, 0, 0, 0.28),
|
||||||
|
inset 0 1px 0 rgba(255, 255, 255, 0.05);
|
||||||
|
padding: clamp(1rem, 1.5vw, 1.5rem);
|
||||||
|
backdrop-filter: blur(18px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bonus-program-caption {
|
||||||
|
font-size: 0.72rem;
|
||||||
|
font-weight: 800;
|
||||||
|
letter-spacing: 0.2em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: rgba(255, 255, 255, 0.48);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bonus-program-stat {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.55rem;
|
||||||
|
border-radius: 1.45rem;
|
||||||
|
border: 1px solid var(--bonus-border);
|
||||||
|
background: rgba(255, 255, 255, 0.02);
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bonus-program-stat__label {
|
||||||
|
font-size: 0.72rem;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.14em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: rgba(255, 255, 255, 0.52);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bonus-program-stat__value {
|
||||||
|
font-size: clamp(1.8rem, 2vw, 2.5rem);
|
||||||
|
line-height: 1;
|
||||||
|
font-weight: 800;
|
||||||
|
letter-spacing: -0.05em;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bonus-program-input {
|
||||||
|
width: 100%;
|
||||||
|
border: 1px solid var(--bonus-border);
|
||||||
|
border-radius: 1.25rem;
|
||||||
|
background: rgba(255, 255, 255, 0.035);
|
||||||
|
color: #fff;
|
||||||
|
padding: 0.95rem 1rem;
|
||||||
|
outline: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bonus-program-input:focus,
|
||||||
|
.bonus-program-input:focus-visible {
|
||||||
|
border-color: rgba(216, 255, 62, 0.3);
|
||||||
|
box-shadow: 0 0 0 3px rgba(216, 255, 62, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bonus-program-primary-button {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
min-height: 3rem;
|
||||||
|
width: 100%;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: var(--bonus-accent);
|
||||||
|
color: #060606;
|
||||||
|
font-weight: 800;
|
||||||
|
letter-spacing: -0.01em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bonus-program-primary-button:disabled {
|
||||||
|
opacity: 0.45;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bonus-program-ghost-button {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
min-height: 2.9rem;
|
||||||
|
border: 1px solid var(--bonus-border);
|
||||||
|
border-radius: 999px;
|
||||||
|
padding: 0 1rem;
|
||||||
|
color: #fff;
|
||||||
|
background: rgba(255, 255, 255, 0.02);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bonus-program-feed-item {
|
||||||
|
border-radius: 1.6rem;
|
||||||
|
border: 1px solid var(--bonus-border);
|
||||||
|
background: rgba(255, 255, 255, 0.02);
|
||||||
|
padding: 1rem 1.05rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bonus-program-inline-link {
|
||||||
|
color: var(--bonus-accent);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bonus-program-empty {
|
||||||
|
border-radius: 1.6rem;
|
||||||
|
border: 1px dashed rgba(255, 255, 255, 0.12);
|
||||||
|
padding: 1rem 1.1rem;
|
||||||
|
color: rgba(255, 255, 255, 0.55);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bonus-program-orbit {
|
||||||
|
position: absolute;
|
||||||
|
border-radius: 999px;
|
||||||
|
filter: blur(60px);
|
||||||
|
pointer-events: none;
|
||||||
|
opacity: 0.42;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bonus-program-orbit--a {
|
||||||
|
top: 4%;
|
||||||
|
left: -6%;
|
||||||
|
width: 15rem;
|
||||||
|
height: 15rem;
|
||||||
|
background: rgba(216, 255, 62, 0.16);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bonus-program-orbit--b {
|
||||||
|
right: -4%;
|
||||||
|
bottom: 8%;
|
||||||
|
width: 14rem;
|
||||||
|
height: 14rem;
|
||||||
|
background: rgba(86, 92, 255, 0.18);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 960px) {
|
||||||
|
.bonus-program-hero {
|
||||||
|
flex-direction: row;
|
||||||
|
align-items: flex-end;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.lk-page-tabs-shell {
|
.lk-page-tabs-shell {
|
||||||
position: relative;
|
position: relative;
|
||||||
z-index: 2;
|
z-index: 2;
|
||||||
|
|||||||
@@ -783,6 +783,13 @@ export type VerifyLoginCodeMutationVariables = Exact<{
|
|||||||
|
|
||||||
export type VerifyLoginCodeMutation = { __typename?: 'Mutation', verifyLoginCode: { __typename?: 'AuthSession', accessToken: string, expiresAt: any, user: { __typename?: 'User', id: string, email: string, fullName: string, role: UserRole, company?: { __typename?: 'Company', id: string } | null } } };
|
export type VerifyLoginCodeMutation = { __typename?: 'Mutation', verifyLoginCode: { __typename?: 'AuthSession', accessToken: string, expiresAt: any, user: { __typename?: 'User', id: string, email: string, fullName: string, role: UserRole, company?: { __typename?: 'Company', id: string } | null } } };
|
||||||
|
|
||||||
|
export type RequestRewardWithdrawalMutationVariables = Exact<{
|
||||||
|
input: RequestRewardWithdrawalInput;
|
||||||
|
}>;
|
||||||
|
|
||||||
|
|
||||||
|
export type RequestRewardWithdrawalMutation = { __typename?: 'Mutation', requestRewardWithdrawal: { __typename?: 'RewardWithdrawalRequest', id: string, amount: number, status: WithdrawalStatus, createdAt: any, updatedAt: any, reviewComment?: string | null } };
|
||||||
|
|
||||||
export type AddProductToCartMutationVariables = Exact<{
|
export type AddProductToCartMutationVariables = Exact<{
|
||||||
productId: Scalars['ID']['input'];
|
productId: Scalars['ID']['input'];
|
||||||
}>;
|
}>;
|
||||||
@@ -1212,6 +1219,40 @@ export function useVerifyLoginCodeMutation(options: VueApolloComposable.UseMutat
|
|||||||
return VueApolloComposable.useMutation<VerifyLoginCodeMutation, VerifyLoginCodeMutationVariables>(VerifyLoginCodeDocument, options);
|
return VueApolloComposable.useMutation<VerifyLoginCodeMutation, VerifyLoginCodeMutationVariables>(VerifyLoginCodeDocument, options);
|
||||||
}
|
}
|
||||||
export type VerifyLoginCodeMutationCompositionFunctionResult = VueApolloComposable.UseMutationReturn<VerifyLoginCodeMutation, VerifyLoginCodeMutationVariables>;
|
export type VerifyLoginCodeMutationCompositionFunctionResult = VueApolloComposable.UseMutationReturn<VerifyLoginCodeMutation, VerifyLoginCodeMutationVariables>;
|
||||||
|
export const RequestRewardWithdrawalDocument = gql`
|
||||||
|
mutation RequestRewardWithdrawal($input: RequestRewardWithdrawalInput!) {
|
||||||
|
requestRewardWithdrawal(input: $input) {
|
||||||
|
id
|
||||||
|
amount
|
||||||
|
status
|
||||||
|
createdAt
|
||||||
|
updatedAt
|
||||||
|
reviewComment
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* __useRequestRewardWithdrawalMutation__
|
||||||
|
*
|
||||||
|
* To run a mutation, you first call `useRequestRewardWithdrawalMutation` within a Vue component and pass it any options that fit your needs.
|
||||||
|
* When your component renders, `useRequestRewardWithdrawalMutation` returns an object that includes:
|
||||||
|
* - A mutate function that you can call at any time to execute the mutation
|
||||||
|
* - Several other properties: https://v4.apollo.vuejs.org/api/use-mutation.html#return
|
||||||
|
*
|
||||||
|
* @param options that will be passed into the mutation, supported options are listed on: https://v4.apollo.vuejs.org/guide-composable/mutation.html#options;
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* const { mutate, loading, error, onDone } = useRequestRewardWithdrawalMutation({
|
||||||
|
* variables: {
|
||||||
|
* input: // value for 'input'
|
||||||
|
* },
|
||||||
|
* });
|
||||||
|
*/
|
||||||
|
export function useRequestRewardWithdrawalMutation(options: VueApolloComposable.UseMutationOptions<RequestRewardWithdrawalMutation, RequestRewardWithdrawalMutationVariables> | ReactiveFunction<VueApolloComposable.UseMutationOptions<RequestRewardWithdrawalMutation, RequestRewardWithdrawalMutationVariables>> = {}) {
|
||||||
|
return VueApolloComposable.useMutation<RequestRewardWithdrawalMutation, RequestRewardWithdrawalMutationVariables>(RequestRewardWithdrawalDocument, options);
|
||||||
|
}
|
||||||
|
export type RequestRewardWithdrawalMutationCompositionFunctionResult = VueApolloComposable.UseMutationReturn<RequestRewardWithdrawalMutation, RequestRewardWithdrawalMutationVariables>;
|
||||||
export const AddProductToCartDocument = gql`
|
export const AddProductToCartDocument = gql`
|
||||||
mutation AddProductToCart($productId: ID!) {
|
mutation AddProductToCart($productId: ID!) {
|
||||||
addProductToCart(productId: $productId) {
|
addProductToCart(productId: $productId) {
|
||||||
|
|||||||
298
app/pages/bonus-program.vue
Normal file
298
app/pages/bonus-program.vue
Normal file
@@ -0,0 +1,298 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { useMutation, useQuery } from '@vue/apollo-composable';
|
||||||
|
import {
|
||||||
|
MeDocument,
|
||||||
|
ReferralStatsDocument,
|
||||||
|
RequestRewardWithdrawalDocument,
|
||||||
|
} from '~/composables/graphql/generated';
|
||||||
|
|
||||||
|
const route = useRoute();
|
||||||
|
|
||||||
|
const meQuery = useQuery(MeDocument);
|
||||||
|
const referralStatsQuery = useQuery(ReferralStatsDocument);
|
||||||
|
const requestWithdrawalMutation = useMutation(RequestRewardWithdrawalDocument, { throws: 'never' });
|
||||||
|
|
||||||
|
const withdrawalAmount = ref('');
|
||||||
|
const withdrawalFeedback = ref('');
|
||||||
|
const withdrawalFeedbackTone = ref<'success' | 'error'>('success');
|
||||||
|
|
||||||
|
const bonusAccount = computed(() => referralStatsQuery.result.value?.referralStats ?? null);
|
||||||
|
const me = computed(() => meQuery.result.value?.me ?? null);
|
||||||
|
const transactions = computed(() => bonusAccount.value?.transactions ?? []);
|
||||||
|
const pendingWithdrawals = computed(() => bonusAccount.value?.pendingWithdrawals ?? []);
|
||||||
|
const availableBalance = computed(() => bonusAccount.value?.availableBalance ?? 0);
|
||||||
|
const canWithdraw = computed(() => availableBalance.value >= 100);
|
||||||
|
const selectedEntry = computed(() => String(route.query.entry || '').trim());
|
||||||
|
|
||||||
|
const entryTitle = computed(() => {
|
||||||
|
if (selectedEntry.value.includes('withdrawal')) {
|
||||||
|
return 'Вы открыли бонусную программу из уведомления о выводе.';
|
||||||
|
}
|
||||||
|
if (selectedEntry.value.includes('balance')) {
|
||||||
|
return 'Вы открыли бонусную программу из уведомления об изменении баланса.';
|
||||||
|
}
|
||||||
|
if (selectedEntry.value) {
|
||||||
|
return 'Вы открыли бонусную программу из специального перехода.';
|
||||||
|
}
|
||||||
|
return 'Отдельный бонусный интерфейс для клиента.';
|
||||||
|
});
|
||||||
|
|
||||||
|
const suggestedWithdrawalAmount = computed(() => {
|
||||||
|
if (availableBalance.value < 100) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
return String(Math.floor(availableBalance.value));
|
||||||
|
});
|
||||||
|
|
||||||
|
watch(
|
||||||
|
suggestedWithdrawalAmount,
|
||||||
|
(value) => {
|
||||||
|
if (!withdrawalAmount.value && value) {
|
||||||
|
withdrawalAmount.value = value;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ immediate: true },
|
||||||
|
);
|
||||||
|
|
||||||
|
function formatMoney(value: number) {
|
||||||
|
return new Intl.NumberFormat('ru-RU', {
|
||||||
|
minimumFractionDigits: 0,
|
||||||
|
maximumFractionDigits: 2,
|
||||||
|
}).format(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDate(value: string) {
|
||||||
|
return new Date(value).toLocaleString('ru-RU');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submitWithdrawal() {
|
||||||
|
withdrawalFeedback.value = '';
|
||||||
|
|
||||||
|
const amount = Number(withdrawalAmount.value);
|
||||||
|
if (!Number.isFinite(amount) || amount < 100) {
|
||||||
|
withdrawalFeedbackTone.value = 'error';
|
||||||
|
withdrawalFeedback.value = 'Минимальная сумма вывода - 100.';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (amount > availableBalance.value) {
|
||||||
|
withdrawalFeedbackTone.value = 'error';
|
||||||
|
withdrawalFeedback.value = 'Сумма вывода не может быть больше доступного баланса.';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await requestWithdrawalMutation.mutate({
|
||||||
|
input: {
|
||||||
|
amount,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const payload = response?.data?.requestRewardWithdrawal;
|
||||||
|
if (!payload) {
|
||||||
|
withdrawalFeedbackTone.value = 'error';
|
||||||
|
withdrawalFeedback.value = requestWithdrawalMutation.error.value?.message || 'Не удалось отправить заявку на вывод.';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
withdrawalFeedbackTone.value = 'success';
|
||||||
|
withdrawalFeedback.value = `Заявка на вывод создана: ${formatMoney(payload.amount)}.`;
|
||||||
|
withdrawalAmount.value = '';
|
||||||
|
await referralStatsQuery.refetch();
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<section class="bonus-program-page space-y-8">
|
||||||
|
<div class="bonus-program-orbit bonus-program-orbit--a" aria-hidden="true" />
|
||||||
|
<div class="bonus-program-orbit bonus-program-orbit--b" aria-hidden="true" />
|
||||||
|
|
||||||
|
<header class="bonus-program-hero">
|
||||||
|
<div class="space-y-3">
|
||||||
|
<p class="bonus-program-kicker">Bonus Program</p>
|
||||||
|
<h1 class="bonus-program-title">
|
||||||
|
Чёрный кабинет бонусной программы
|
||||||
|
</h1>
|
||||||
|
<p class="bonus-program-copy">
|
||||||
|
{{ entryTitle }}
|
||||||
|
Здесь отдельно живут баланс, начисления, выводы и переходы из бонусных уведомлений.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-wrap gap-3">
|
||||||
|
<NuxtLink to="/messages" class="bonus-program-ghost-button">
|
||||||
|
Message board
|
||||||
|
</NuxtLink>
|
||||||
|
<NuxtLink to="/notifications" class="bonus-program-ghost-button">
|
||||||
|
История уведомлений
|
||||||
|
</NuxtLink>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div v-if="referralStatsQuery.loading.value || meQuery.loading.value" class="bonus-program-panel">
|
||||||
|
Загружаем бонусную программу...
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<template v-else>
|
||||||
|
<section class="grid gap-4 xl:grid-cols-[1.3fr_0.9fr]">
|
||||||
|
<article class="bonus-program-panel">
|
||||||
|
<p class="bonus-program-caption">Аккаунт</p>
|
||||||
|
<div class="mt-4 flex flex-col gap-5 lg:flex-row lg:items-end lg:justify-between">
|
||||||
|
<div class="space-y-2">
|
||||||
|
<h2 class="text-4xl font-black tracking-[-0.05em] text-white">
|
||||||
|
{{ me?.fullName || 'Клиент бонусной программы' }}
|
||||||
|
</h2>
|
||||||
|
<p class="text-sm leading-6 text-white/65">
|
||||||
|
Отдельная зона для бонусных начислений и статусов выводов.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="space-y-2 text-left lg:text-right">
|
||||||
|
<p class="bonus-program-caption">Доступный баланс</p>
|
||||||
|
<p class="text-5xl font-black tracking-[-0.05em] text-white">
|
||||||
|
{{ formatMoney(availableBalance) }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-6 grid gap-3 md:grid-cols-3">
|
||||||
|
<div class="bonus-program-stat">
|
||||||
|
<span class="bonus-program-stat__label">Рефералы</span>
|
||||||
|
<span class="bonus-program-stat__value">{{ bonusAccount?.referralsCount ?? 0 }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="bonus-program-stat">
|
||||||
|
<span class="bonus-program-stat__label">Начисления</span>
|
||||||
|
<span class="bonus-program-stat__value">{{ transactions.length }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="bonus-program-stat">
|
||||||
|
<span class="bonus-program-stat__label">Активные выводы</span>
|
||||||
|
<span class="bonus-program-stat__value">{{ pendingWithdrawals.length }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article class="bonus-program-panel">
|
||||||
|
<p class="bonus-program-caption">Вывод бонусов</p>
|
||||||
|
<div class="mt-4 space-y-4">
|
||||||
|
<p class="text-sm leading-6 text-white/70">
|
||||||
|
При изменении статуса вывода клиент получает отдельное уведомление и возвращается именно в этот экран.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<label class="block space-y-2">
|
||||||
|
<span class="text-xs font-semibold uppercase tracking-[0.14em] text-white/55">Сумма заявки</span>
|
||||||
|
<input
|
||||||
|
v-model="withdrawalAmount"
|
||||||
|
type="number"
|
||||||
|
min="100"
|
||||||
|
step="1"
|
||||||
|
class="bonus-program-input"
|
||||||
|
placeholder="Например, 1500"
|
||||||
|
>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<button
|
||||||
|
class="bonus-program-primary-button"
|
||||||
|
:disabled="requestWithdrawalMutation.loading.value || !canWithdraw"
|
||||||
|
@click="submitWithdrawal"
|
||||||
|
>
|
||||||
|
{{
|
||||||
|
requestWithdrawalMutation.loading.value
|
||||||
|
? 'Отправляем заявку...'
|
||||||
|
: canWithdraw
|
||||||
|
? 'Подать заявку на вывод'
|
||||||
|
: 'Недостаточно бонусов для вывода'
|
||||||
|
}}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-if="withdrawalFeedback"
|
||||||
|
class="rounded-[24px] border px-4 py-3 text-sm"
|
||||||
|
:class="withdrawalFeedbackTone === 'success' ? 'border-[#184e31] bg-[#0f1f16] text-[#dff7e8]' : 'border-[#6a2626] bg-[#1b1010] text-[#ffd4d4]'"
|
||||||
|
>
|
||||||
|
{{ withdrawalFeedback }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="grid gap-4 xl:grid-cols-[1.05fr_0.95fr]">
|
||||||
|
<article class="bonus-program-panel">
|
||||||
|
<div class="flex items-center justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<p class="bonus-program-caption">Начисления</p>
|
||||||
|
<h2 class="mt-2 text-2xl font-black tracking-[-0.04em] text-white">История бонусов</h2>
|
||||||
|
</div>
|
||||||
|
<span class="rounded-full border border-white/10 bg-white/5 px-3 py-1 text-xs font-semibold uppercase tracking-[0.12em] text-white/55">
|
||||||
|
{{ transactions.length }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="transactions.length === 0" class="bonus-program-empty mt-5">
|
||||||
|
Пока нет начислений. Когда придут первые бонусы, они появятся здесь отдельной чёрной лентой.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else class="mt-5 space-y-3">
|
||||||
|
<article
|
||||||
|
v-for="transaction in transactions"
|
||||||
|
:key="transaction.id"
|
||||||
|
class="bonus-program-feed-item"
|
||||||
|
>
|
||||||
|
<div class="flex flex-col gap-3 md:flex-row md:items-start md:justify-between">
|
||||||
|
<div class="space-y-2">
|
||||||
|
<p class="text-lg font-bold text-white">+{{ formatMoney(transaction.amount) }}</p>
|
||||||
|
<p class="text-sm leading-6 text-white/72">{{ transaction.reason }}</p>
|
||||||
|
<p class="text-xs uppercase tracking-[0.12em] text-white/40">{{ formatDate(transaction.createdAt) }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<NuxtLink
|
||||||
|
v-if="transaction.orderId"
|
||||||
|
to="/orders"
|
||||||
|
class="bonus-program-inline-link"
|
||||||
|
>
|
||||||
|
Открыть заказы
|
||||||
|
</NuxtLink>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article class="bonus-program-panel">
|
||||||
|
<div class="flex items-center justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<p class="bonus-program-caption">Выводы</p>
|
||||||
|
<h2 class="mt-2 text-2xl font-black tracking-[-0.04em] text-white">Текущий статус заявок</h2>
|
||||||
|
</div>
|
||||||
|
<span class="rounded-full border border-white/10 bg-white/5 px-3 py-1 text-xs font-semibold uppercase tracking-[0.12em] text-white/55">
|
||||||
|
{{ pendingWithdrawals.length }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="pendingWithdrawals.length === 0" class="bonus-program-empty mt-5">
|
||||||
|
Активных выводов сейчас нет. Как только менеджер получит заявку, она появится в этом блоке.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else class="mt-5 space-y-3">
|
||||||
|
<article
|
||||||
|
v-for="withdrawal in pendingWithdrawals"
|
||||||
|
:key="withdrawal.id"
|
||||||
|
class="bonus-program-feed-item"
|
||||||
|
>
|
||||||
|
<div class="space-y-2">
|
||||||
|
<div class="flex flex-wrap items-center gap-3">
|
||||||
|
<p class="text-lg font-bold text-white">{{ formatMoney(withdrawal.amount) }}</p>
|
||||||
|
<span class="rounded-full border border-white/10 bg-white/5 px-3 py-1 text-xs font-semibold uppercase tracking-[0.12em] text-white/55">
|
||||||
|
{{ withdrawal.status }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p class="text-xs uppercase tracking-[0.12em] text-white/40">{{ formatDate(withdrawal.createdAt) }}</p>
|
||||||
|
<p v-if="withdrawal.reviewComment" class="text-sm leading-6 text-white/72">
|
||||||
|
{{ withdrawal.reviewComment }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
@@ -30,9 +30,15 @@ const withdrawalsQuery = useQuery(ManagerWithdrawalRequestsDocument, {
|
|||||||
status: 'PENDING',
|
status: 'PENDING',
|
||||||
});
|
});
|
||||||
|
|
||||||
const activeTab = computed<'balances' | 'withdrawals'>(() => (
|
const activeTab = computed<'balances' | 'withdrawals' | 'manager'>(() => {
|
||||||
route.query.tab === 'withdrawals' ? 'withdrawals' : 'balances'
|
if (route.query.tab === 'withdrawals') {
|
||||||
));
|
return 'withdrawals';
|
||||||
|
}
|
||||||
|
if (route.query.tab === 'manager') {
|
||||||
|
return 'manager';
|
||||||
|
}
|
||||||
|
return 'balances';
|
||||||
|
});
|
||||||
|
|
||||||
const balances = computed<BalanceItem[]>(() => balancesQuery.result.value?.managerBonusBalances ?? []);
|
const balances = computed<BalanceItem[]>(() => balancesQuery.result.value?.managerBonusBalances ?? []);
|
||||||
const referralLinks = computed<ReferralLinkItem[]>(() => referralLinksQuery.result.value?.managerReferralLinks ?? []);
|
const referralLinks = computed<ReferralLinkItem[]>(() => referralLinksQuery.result.value?.managerReferralLinks ?? []);
|
||||||
@@ -165,14 +171,12 @@ function formatAmount(value: number) {
|
|||||||
<UiSectionSearchHero
|
<UiSectionSearchHero
|
||||||
v-model="search"
|
v-model="search"
|
||||||
title="Бонусы"
|
title="Бонусы"
|
||||||
:search-placeholder="activeTab === 'balances' ? 'Клиент, связанный клиент, email или процент' : 'Пользователь, сумма или статус'"
|
:search-placeholder="activeTab === 'balances'
|
||||||
>
|
? 'Клиент, связанный клиент, email или процент'
|
||||||
<template #controls>
|
: activeTab === 'withdrawals'
|
||||||
<NuxtLink to="/bonus-system/referrals/new" class="btn btn-primary border-0">
|
? 'Пользователь, сумма или статус'
|
||||||
Добавить связь
|
: 'Сценарии для менеджера'"
|
||||||
</NuxtLink>
|
/>
|
||||||
</template>
|
|
||||||
</UiSectionSearchHero>
|
|
||||||
|
|
||||||
<template v-if="activeTab === 'balances'">
|
<template v-if="activeTab === 'balances'">
|
||||||
<div v-if="balancesQuery.loading.value || referralLinksQuery.loading.value || usersQuery.loading.value" class="manager-empty-state">
|
<div v-if="balancesQuery.loading.value || referralLinksQuery.loading.value || usersQuery.loading.value" class="manager-empty-state">
|
||||||
@@ -207,6 +211,54 @@ function formatAmount(value: number) {
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<template v-else-if="activeTab === 'manager'">
|
||||||
|
<div class="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
|
||||||
|
<NuxtLink
|
||||||
|
to="/bonus-system/referrals/new"
|
||||||
|
class="surface-card surface-card-interactive rounded-3xl p-5"
|
||||||
|
>
|
||||||
|
<p class="text-[11px] font-semibold uppercase tracking-[0.18em] text-[#6a8a76]">Связки</p>
|
||||||
|
<h2 class="mt-3 text-xl font-black tracking-[-0.03em] text-[#123824]">Добавить бонусную связь</h2>
|
||||||
|
<p class="mt-2 text-sm leading-6 text-[#557562]">
|
||||||
|
Связать клиентов карточным сценарием и задать процент бонусной программы.
|
||||||
|
</p>
|
||||||
|
</NuxtLink>
|
||||||
|
|
||||||
|
<NuxtLink
|
||||||
|
to="/bonus-system/transactions/new"
|
||||||
|
class="surface-card surface-card-interactive rounded-3xl p-5"
|
||||||
|
>
|
||||||
|
<p class="text-[11px] font-semibold uppercase tracking-[0.18em] text-[#6a8a76]">Начисления</p>
|
||||||
|
<h2 class="mt-3 text-xl font-black tracking-[-0.03em] text-[#123824]">Ручное начисление</h2>
|
||||||
|
<p class="mt-2 text-sm leading-6 text-[#557562]">
|
||||||
|
Добавить бонусную транзакцию вручную, если нужно быстро выдать бонус вне автоматического сценария.
|
||||||
|
</p>
|
||||||
|
</NuxtLink>
|
||||||
|
|
||||||
|
<NuxtLink
|
||||||
|
to="/bonus-system?tab=withdrawals"
|
||||||
|
class="surface-card surface-card-interactive rounded-3xl p-5"
|
||||||
|
>
|
||||||
|
<p class="text-[11px] font-semibold uppercase tracking-[0.18em] text-[#6a8a76]">Выводы</p>
|
||||||
|
<h2 class="mt-3 text-xl font-black tracking-[-0.03em] text-[#123824]">Проверить выплаты</h2>
|
||||||
|
<p class="mt-2 text-sm leading-6 text-[#557562]">
|
||||||
|
Открыть витрину заявок на вывод и разбирать новые обращения по карточкам.
|
||||||
|
</p>
|
||||||
|
</NuxtLink>
|
||||||
|
|
||||||
|
<NuxtLink
|
||||||
|
to="/bonus-program?entry=manager-preview"
|
||||||
|
class="surface-card surface-card-interactive rounded-3xl bg-[#0d0d0f] p-5 text-white"
|
||||||
|
>
|
||||||
|
<p class="text-[11px] font-semibold uppercase tracking-[0.18em] text-white/55">Client view</p>
|
||||||
|
<h2 class="mt-3 text-xl font-black tracking-[-0.03em] text-white">Открыть бонусный экран клиента</h2>
|
||||||
|
<p class="mt-2 text-sm leading-6 text-white/72">
|
||||||
|
Посмотреть, куда ведёт бонусное уведомление и как выглядит отдельный интерфейс программы.
|
||||||
|
</p>
|
||||||
|
</NuxtLink>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<div v-if="withdrawalsQuery.loading.value" class="manager-empty-state">
|
<div v-if="withdrawalsQuery.loading.value" class="manager-empty-state">
|
||||||
Загружаем заявки...
|
Загружаем заявки...
|
||||||
|
|||||||
361
app/pages/messages.vue
Normal file
361
app/pages/messages.vue
Normal file
@@ -0,0 +1,361 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { useQuery } from '@vue/apollo-composable';
|
||||||
|
import {
|
||||||
|
MeDocument,
|
||||||
|
MyMessengerConnectionsDocument,
|
||||||
|
} from '~/composables/graphql/generated';
|
||||||
|
|
||||||
|
type DeliveryChannel = 'EMAIL' | 'TELEGRAM' | 'MAX';
|
||||||
|
|
||||||
|
type MessagePreview = {
|
||||||
|
channel: DeliveryChannel;
|
||||||
|
mode: 'live' | 'preview';
|
||||||
|
subject?: string;
|
||||||
|
body: string[];
|
||||||
|
buttonLabel?: string;
|
||||||
|
buttonTo?: string;
|
||||||
|
note: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type MessageScenario = {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
trigger: string;
|
||||||
|
description: string;
|
||||||
|
previews: MessagePreview[];
|
||||||
|
};
|
||||||
|
|
||||||
|
const meQuery = useQuery(MeDocument);
|
||||||
|
const connectionsQuery = useQuery(MyMessengerConnectionsDocument);
|
||||||
|
|
||||||
|
const me = computed(() => meQuery.result.value?.me ?? null);
|
||||||
|
|
||||||
|
const channelState = computed<Record<DeliveryChannel, string>>(() => {
|
||||||
|
const connections = connectionsQuery.result.value?.myMessengerConnections ?? [];
|
||||||
|
const telegramConnected = connections.some((item) => item.type === 'TELEGRAM' && item.isActive);
|
||||||
|
const maxConnected = connections.some((item) => item.type === 'MAX' && item.isActive);
|
||||||
|
|
||||||
|
return {
|
||||||
|
EMAIL: me.value?.email ? `Email ${me.value.email}` : 'Email не определён',
|
||||||
|
TELEGRAM: telegramConnected ? 'Telegram подключён' : 'Telegram ещё не подключён',
|
||||||
|
MAX: maxConnected ? 'Max подключён' : 'Max ещё не подключён',
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const messageScenarios = computed<MessageScenario[]>(() => {
|
||||||
|
const companyGreeting = me.value?.fullName || 'Клиент Фрегат';
|
||||||
|
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
id: 'order-offer',
|
||||||
|
title: 'Публикация расчёта по заказу',
|
||||||
|
trigger: 'Менеджер заполнил стоимость, логистику и опубликовал предложение клиенту.',
|
||||||
|
description: 'Этот сценарий нужен для согласования финального текста по заказам и кнопки перехода в карточку заказа.',
|
||||||
|
previews: [
|
||||||
|
{
|
||||||
|
channel: 'EMAIL',
|
||||||
|
mode: 'preview',
|
||||||
|
subject: 'Fregat: предложение по заказу готово',
|
||||||
|
body: [
|
||||||
|
`Здравствуйте, ${companyGreeting}.`,
|
||||||
|
'По вашему заказу менеджер подготовил предложение: стоимость, логистика и условия уже доступны в личном кабинете.',
|
||||||
|
'Откройте карточку заказа, чтобы проверить детали и подтвердить запуск.',
|
||||||
|
],
|
||||||
|
buttonLabel: 'Открыть заказ',
|
||||||
|
buttonTo: '/orders',
|
||||||
|
note: 'Email-версия пока как локальный шаблон для согласования.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
channel: 'TELEGRAM',
|
||||||
|
mode: 'live',
|
||||||
|
body: [
|
||||||
|
'Заказ FRG-2401 изменил статус: WAITING_DOUBLE_CONFIRM.',
|
||||||
|
'Комментарий: менеджер опубликовал предложение и готов к запуску.',
|
||||||
|
],
|
||||||
|
buttonLabel: 'Открыть заказ',
|
||||||
|
buttonTo: '/orders',
|
||||||
|
note: 'Messenger-кнопка уже соответствует текущей логике перехода в заказ.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
channel: 'MAX',
|
||||||
|
mode: 'live',
|
||||||
|
body: [
|
||||||
|
'Заказ FRG-2401 изменил статус: WAITING_DOUBLE_CONFIRM.',
|
||||||
|
'Комментарий: менеджер опубликовал предложение и готов к запуску.',
|
||||||
|
],
|
||||||
|
buttonLabel: 'Открыть заказ',
|
||||||
|
buttonTo: '/orders',
|
||||||
|
note: 'Для Max показываем тот же сценарий с общей кнопкой открытия заказа.',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'order-tracking',
|
||||||
|
title: 'Трекинг-обновление по заказу',
|
||||||
|
trigger: 'По заказу пришёл новый статус исполнения, доставки или производства.',
|
||||||
|
description: 'Здесь мы согласуем формат сообщения, когда заказ уже пошёл в работу и клиенту важен быстрый переход в детали.',
|
||||||
|
previews: [
|
||||||
|
{
|
||||||
|
channel: 'EMAIL',
|
||||||
|
mode: 'preview',
|
||||||
|
subject: 'Fregat: статус заказа обновлён',
|
||||||
|
body: [
|
||||||
|
`Здравствуйте, ${companyGreeting}.`,
|
||||||
|
'По заказу FRG-2401 пришло новое обновление: производство в работе, следующая контрольная точка - отгрузка.',
|
||||||
|
'Откройте заказ, чтобы увидеть логистику и актуальные параметры.',
|
||||||
|
],
|
||||||
|
buttonLabel: 'Перейти к заказу',
|
||||||
|
buttonTo: '/orders',
|
||||||
|
note: 'Почтовая версия нужна как отдельный шаблон поверх messenger-каналов.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
channel: 'TELEGRAM',
|
||||||
|
mode: 'live',
|
||||||
|
body: [
|
||||||
|
'Заказ FRG-2401 изменил статус: IN_PROGRESS.',
|
||||||
|
'Комментарий: заказ передан в производство и движется по плану.',
|
||||||
|
],
|
||||||
|
buttonLabel: 'Открыть заказ',
|
||||||
|
buttonTo: '/orders',
|
||||||
|
note: 'Это текущий реальный паттерн для order-status уведомлений.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
channel: 'MAX',
|
||||||
|
mode: 'live',
|
||||||
|
body: [
|
||||||
|
'Заказ FRG-2401 изменил статус: IN_PROGRESS.',
|
||||||
|
'Комментарий: заказ передан в производство и движется по плану.',
|
||||||
|
],
|
||||||
|
buttonLabel: 'Открыть заказ',
|
||||||
|
buttonTo: '/orders',
|
||||||
|
note: 'Max получает идентичную структуру, чтобы каналы были синхронизированы.',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'bonus-balance',
|
||||||
|
title: 'Изменение бонусного баланса',
|
||||||
|
trigger: 'Клиенту начислили реферальный бонус или ручную бонусную транзакцию.',
|
||||||
|
description: 'Для бонусной программы делаем отдельный визуальный мир. Клик из уведомления должен уводить не в обычный кабинет, а в бонусный интерфейс.',
|
||||||
|
previews: [
|
||||||
|
{
|
||||||
|
channel: 'EMAIL',
|
||||||
|
mode: 'preview',
|
||||||
|
subject: 'Fregat Bonus: баланс обновлён',
|
||||||
|
body: [
|
||||||
|
`Здравствуйте, ${companyGreeting}.`,
|
||||||
|
'Ваш бонусный баланс изменился. Мы подготовили отдельный экран бонусной программы, чтобы вы видели начисления, выводы и историю в одном месте.',
|
||||||
|
'Откройте бонусный интерфейс и проверьте текущее состояние счёта.',
|
||||||
|
],
|
||||||
|
buttonLabel: 'Открыть бонусную программу',
|
||||||
|
buttonTo: '/bonus-program?entry=email-balance',
|
||||||
|
note: 'Email-сообщение пока как согласуемый шаблон, но маршрут уже готов.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
channel: 'TELEGRAM',
|
||||||
|
mode: 'live',
|
||||||
|
body: [
|
||||||
|
'Начислен бонус: 1250. Причина: реферальное начисление за заказ FRG-2401.',
|
||||||
|
'Кнопка ниже открывает отдельный бонусный экран, а не обычный профиль.',
|
||||||
|
],
|
||||||
|
buttonLabel: 'Открыть бонусную программу',
|
||||||
|
buttonTo: '/bonus-program?entry=telegram-balance',
|
||||||
|
note: 'Эту же точку входа теперь можно использовать и в реальном Telegram-уведомлении.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
channel: 'MAX',
|
||||||
|
mode: 'live',
|
||||||
|
body: [
|
||||||
|
'Начислен бонус: 1250. Причина: реферальное начисление за заказ FRG-2401.',
|
||||||
|
'Кнопка ниже открывает отдельный бонусный экран, а не обычный профиль.',
|
||||||
|
],
|
||||||
|
buttonLabel: 'Открыть бонусную программу',
|
||||||
|
buttonTo: '/bonus-program?entry=max-balance',
|
||||||
|
note: 'Max ведёт в тот же отдельный бонусный интерфейс.',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'bonus-withdrawal',
|
||||||
|
title: 'Решение по заявке на вывод',
|
||||||
|
trigger: 'Менеджер проверил заявку на вывод бонусов и оставил решение.',
|
||||||
|
description: 'Здесь важен единый UX: человек получает решение в мессенджере и сразу попадает в бонусный экран, где видит историю и статус.',
|
||||||
|
previews: [
|
||||||
|
{
|
||||||
|
channel: 'EMAIL',
|
||||||
|
mode: 'preview',
|
||||||
|
subject: 'Fregat Bonus: заявка на вывод обновлена',
|
||||||
|
body: [
|
||||||
|
`Здравствуйте, ${companyGreeting}.`,
|
||||||
|
'По вашей заявке на вывод бонусов появилось новое решение. Внутри бонусной программы уже отображён актуальный статус и комментарий менеджера.',
|
||||||
|
'Откройте бонусный кабинет, чтобы посмотреть результат.',
|
||||||
|
],
|
||||||
|
buttonLabel: 'Проверить вывод',
|
||||||
|
buttonTo: '/bonus-program?entry=email-withdrawal',
|
||||||
|
note: 'Почтовый шаблон пока служит для визуального согласования.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
channel: 'TELEGRAM',
|
||||||
|
mode: 'live',
|
||||||
|
body: [
|
||||||
|
'Заявка на вывод вознаграждения обновлена: APPROVED.',
|
||||||
|
'Комментарий: выплата подтверждена и передана в обработку.',
|
||||||
|
],
|
||||||
|
buttonLabel: 'Проверить бонусную программу',
|
||||||
|
buttonTo: '/bonus-program?entry=telegram-withdrawal',
|
||||||
|
note: 'Теперь такую кнопку можно привязать к реальному уведомлению о выводе.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
channel: 'MAX',
|
||||||
|
mode: 'live',
|
||||||
|
body: [
|
||||||
|
'Заявка на вывод вознаграждения обновлена: APPROVED.',
|
||||||
|
'Комментарий: выплата подтверждена и передана в обработку.',
|
||||||
|
],
|
||||||
|
buttonLabel: 'Проверить бонусную программу',
|
||||||
|
buttonTo: '/bonus-program?entry=max-withdrawal',
|
||||||
|
note: 'Маршрут единый: отдельный бонусный экран на этом же домене.',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
});
|
||||||
|
|
||||||
|
function channelLabel(channel: DeliveryChannel) {
|
||||||
|
if (channel === 'EMAIL') {
|
||||||
|
return 'Email';
|
||||||
|
}
|
||||||
|
if (channel === 'TELEGRAM') {
|
||||||
|
return 'Telegram';
|
||||||
|
}
|
||||||
|
return 'Max';
|
||||||
|
}
|
||||||
|
|
||||||
|
function channelBadgeClass(channel: DeliveryChannel) {
|
||||||
|
if (channel === 'EMAIL') {
|
||||||
|
return 'bg-[#f3f4f6] text-[#111827]';
|
||||||
|
}
|
||||||
|
if (channel === 'TELEGRAM') {
|
||||||
|
return 'bg-[#dff3ff] text-[#0f5d92]';
|
||||||
|
}
|
||||||
|
return 'bg-[#edf0ff] text-[#3a46a4]';
|
||||||
|
}
|
||||||
|
|
||||||
|
function modeLabel(mode: MessagePreview['mode']) {
|
||||||
|
return mode === 'live' ? 'Live route' : 'Preview';
|
||||||
|
}
|
||||||
|
|
||||||
|
function modeClass(mode: MessagePreview['mode']) {
|
||||||
|
return mode === 'live'
|
||||||
|
? 'bg-[#def7e8] text-[#0d854a]'
|
||||||
|
: 'bg-[#fff1d7] text-[#9a6100]';
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<section class="space-y-6">
|
||||||
|
<div class="manager-hero">
|
||||||
|
<p class="manager-eyebrow">Message Board</p>
|
||||||
|
<h1 class="manager-title">Локальная витрина уведомлений</h1>
|
||||||
|
<p class="manager-copy">
|
||||||
|
Здесь собраны шаблоны сообщений для email, Telegram и Max: сами тексты, кнопки и точки входа.
|
||||||
|
Это удобная доска, чтобы быстро согласовать контент до полной привязки к продовым событиям.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="surface-card rounded-3xl p-5">
|
||||||
|
<div class="flex flex-wrap gap-3 text-sm">
|
||||||
|
<span
|
||||||
|
v-for="(state, channel) in channelState"
|
||||||
|
:key="channel"
|
||||||
|
class="manager-channel-chip"
|
||||||
|
>
|
||||||
|
<span class="manager-channel-dot" :class="channel === 'EMAIL' ? 'bg-[#111827]' : channel === 'TELEGRAM' ? 'bg-[#229ed9]' : 'bg-[#2b7fff]'">
|
||||||
|
{{ channel === 'EMAIL' ? 'EM' : channel === 'TELEGRAM' ? 'TG' : 'MX' }}
|
||||||
|
</span>
|
||||||
|
<span>{{ state }}</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-4 flex flex-wrap gap-3">
|
||||||
|
<NuxtLink to="/bonus-program?entry=message-board" class="btn border-0 bg-[#111827] text-white hover:bg-[#000]">
|
||||||
|
Открыть бонусный экран
|
||||||
|
</NuxtLink>
|
||||||
|
<NuxtLink to="/notifications" class="btn btn-outline border-[#d7e9de] bg-white">
|
||||||
|
Вернуться к уведомлениям
|
||||||
|
</NuxtLink>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="space-y-5">
|
||||||
|
<article
|
||||||
|
v-for="scenario in messageScenarios"
|
||||||
|
:key="scenario.id"
|
||||||
|
class="surface-card rounded-3xl p-5"
|
||||||
|
>
|
||||||
|
<div class="space-y-2">
|
||||||
|
<div class="flex flex-wrap items-start justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<h2 class="text-2xl font-black tracking-[-0.03em] text-[#123824]">{{ scenario.title }}</h2>
|
||||||
|
<p class="mt-1 text-sm font-semibold text-[#355947]">{{ scenario.trigger }}</p>
|
||||||
|
</div>
|
||||||
|
<span class="rounded-full bg-[#eef7f1] px-3 py-1 text-xs font-semibold uppercase tracking-[0.12em] text-[#0d854a]">
|
||||||
|
{{ scenario.previews.length }} канала
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p class="max-w-3xl text-sm leading-6 text-[#557562]">{{ scenario.description }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-5 grid gap-4 xl:grid-cols-3">
|
||||||
|
<section
|
||||||
|
v-for="preview in scenario.previews"
|
||||||
|
:key="`${scenario.id}-${preview.channel}`"
|
||||||
|
class="rounded-[28px] border border-[#deebe4] bg-[#fbfdfb] p-4"
|
||||||
|
>
|
||||||
|
<div class="flex flex-wrap items-center gap-2">
|
||||||
|
<span class="rounded-full px-3 py-1 text-xs font-semibold uppercase tracking-[0.12em]" :class="channelBadgeClass(preview.channel)">
|
||||||
|
{{ channelLabel(preview.channel) }}
|
||||||
|
</span>
|
||||||
|
<span class="rounded-full px-3 py-1 text-xs font-semibold" :class="modeClass(preview.mode)">
|
||||||
|
{{ modeLabel(preview.mode) }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-4 rounded-[24px] bg-white p-4 shadow-[0_14px_34px_rgba(18,56,36,0.06)]">
|
||||||
|
<p v-if="preview.subject" class="text-xs font-semibold uppercase tracking-[0.12em] text-[#5c7b69]">
|
||||||
|
{{ preview.subject }}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="mt-3 space-y-3 text-sm leading-6 text-[#123824]">
|
||||||
|
<p
|
||||||
|
v-for="line in preview.body"
|
||||||
|
:key="line"
|
||||||
|
>
|
||||||
|
{{ line }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="preview.buttonLabel" class="mt-4">
|
||||||
|
<NuxtLink
|
||||||
|
v-if="preview.buttonTo"
|
||||||
|
:to="preview.buttonTo"
|
||||||
|
class="btn h-11 rounded-full border-0 bg-[#139957] px-5 text-white hover:bg-[#0d854a]"
|
||||||
|
>
|
||||||
|
{{ preview.buttonLabel }}
|
||||||
|
</NuxtLink>
|
||||||
|
<span
|
||||||
|
v-else
|
||||||
|
class="inline-flex rounded-full bg-[#139957] px-5 py-3 text-sm font-semibold text-white"
|
||||||
|
>
|
||||||
|
{{ preview.buttonLabel }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="mt-4 text-sm leading-6 text-[#557562]">{{ preview.note }}</p>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
@@ -118,6 +118,28 @@ async function sendTest() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="surface-card rounded-3xl p-5">
|
<div class="surface-card rounded-3xl p-5">
|
||||||
|
<div class="rounded-3xl border border-[#d6ebde] bg-[#f8fbf9] p-4">
|
||||||
|
<div class="flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between">
|
||||||
|
<div class="space-y-1">
|
||||||
|
<h2 class="text-xl font-bold text-[#123824]">Message board и бонусный экран</h2>
|
||||||
|
<p class="text-sm leading-6 text-[#557562]">
|
||||||
|
Для бонусных изменений теперь можно согласовывать отдельные сообщения и отдельную точку входа:
|
||||||
|
клик из уведомления открывает специальный бонусный интерфейс, а не обычный профиль.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-wrap gap-3">
|
||||||
|
<NuxtLink to="/messages" class="btn border-0 bg-[#111827] text-white hover:bg-[#000000]">
|
||||||
|
Открыть message board
|
||||||
|
</NuxtLink>
|
||||||
|
<NuxtLink to="/bonus-program?entry=notifications-preview" class="btn btn-outline border-[#d7e9de] bg-white">
|
||||||
|
Открыть бонусный экран
|
||||||
|
</NuxtLink>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-5">
|
||||||
<div class="space-y-4">
|
<div class="space-y-4">
|
||||||
<h2 class="text-xl font-bold text-[#123824]">Подключение каналов</h2>
|
<h2 class="text-xl font-bold text-[#123824]">Подключение каналов</h2>
|
||||||
|
|
||||||
@@ -267,5 +289,6 @@ async function sendTest() {
|
|||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -139,6 +139,18 @@ const defaultDeliveryAddress = computed(() => deliveryAddresses.value.find((item
|
|||||||
}}
|
}}
|
||||||
</p>
|
</p>
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
|
|
||||||
|
<NuxtLink to="/bonus-program?entry=profile" class="block rounded-3xl bg-[#0d0d0f] p-5 text-white transition hover:shadow-[0_20px_48px_rgba(8,8,10,0.24)]">
|
||||||
|
<div class="mb-2 flex items-center justify-between gap-2">
|
||||||
|
<p class="text-lg font-bold">Бонусная программа</p>
|
||||||
|
<span class="rounded-full border border-white/10 bg-white/10 px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.14em] text-white/75">
|
||||||
|
Separate UI
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p class="text-sm leading-6 text-white/72">
|
||||||
|
Отдельный чёрный экран для бонусов, начислений и заявок на вывод. Именно туда можно вести пользователя из бонусных уведомлений.
|
||||||
|
</p>
|
||||||
|
</NuxtLink>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -75,6 +75,21 @@ async function connectMessenger(channel: 'TELEGRAM' | 'MAX') {
|
|||||||
Подключите Telegram и Max, чтобы получать статусы заказов и важные уведомления в удобном канале.
|
Подключите Telegram и Max, чтобы получать статусы заказов и важные уведомления в удобном канале.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
<div class="mt-4 rounded-3xl border border-[#d6ebde] bg-[#f8fbf9] p-4">
|
||||||
|
<p class="text-sm font-semibold text-[#123824]">Бонусная программа теперь может жить в отдельном экране</p>
|
||||||
|
<p class="mt-2 text-sm leading-6 text-[#557562]">
|
||||||
|
Для бонусных начислений и выводов можно отправлять отдельное сообщение с кнопкой, которое ведёт в специальный бонусный интерфейс.
|
||||||
|
</p>
|
||||||
|
<div class="mt-3 flex flex-wrap gap-3">
|
||||||
|
<NuxtLink to="/messages" class="btn border-0 bg-[#111827] text-white hover:bg-[#000000]">
|
||||||
|
Message board
|
||||||
|
</NuxtLink>
|
||||||
|
<NuxtLink to="/bonus-program?entry=profile-notifications" class="btn btn-outline border-[#d7e9de] bg-white">
|
||||||
|
Открыть бонусный экран
|
||||||
|
</NuxtLink>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="mt-4 space-y-3">
|
<div class="mt-4 space-y-3">
|
||||||
<div class="rounded-2xl bg-[#f8fbf9] p-4 transition hover:shadow-md">
|
<div class="rounded-2xl bg-[#f8fbf9] p-4 transition hover:shadow-md">
|
||||||
<p class="font-semibold">Telegram</p>
|
<p class="font-semibold">Telegram</p>
|
||||||
|
|||||||
10
graphql/operations/bonus/request-reward-withdrawal.graphql
Normal file
10
graphql/operations/bonus/request-reward-withdrawal.graphql
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
mutation RequestRewardWithdrawal($input: RequestRewardWithdrawalInput!) {
|
||||||
|
requestRewardWithdrawal(input: $input) {
|
||||||
|
id
|
||||||
|
amount
|
||||||
|
status
|
||||||
|
createdAt
|
||||||
|
updatedAt
|
||||||
|
reviewComment
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user