Profitkit / Мобильное B2B-приложение
Craft Cartel: мобильное приложение для оптовых заказов

Оптовому клиенту мало выбрать товар. После заказа ему нужно следить за исполнением, возвращаться к документам и сверять расчёты с поставщиком. В Craft Cartel мы собрали эти задачи в мобильном приложении: каталог напитков и снеков соседствует с личным кабинетом, историей заказов и запросом актов сверки.
Мы связали интерфейс на Vue с серверной частью на «1С-Битрикс» и выпустили приложение для iOS и Android. Расскажу, как устроен путь клиента от просмотра ассортимента до возвращения к заказам и какие технические решения помогают пройти его с телефона.

Каталог, параметры товара и выбор условий фильтрации. Ассортимент и цены на экранах — на момент съёмки.
Каталог становится частью рабочего кабинета
Craft Cartel — оптовый поставщик крафтового пива, сидра, безалкогольных напитков и снеков. Это определяет устройство каталога: категории разделены на розлив и фасовку, рядом с названием товара указаны производитель, тара, объём и характеристики напитка. Клиент сравнивает позиции по параметрам закупки.
Основой интерфейса служит Vue. Переходами между каталогом, карточкой и корзиной управляет Vue Router. Общие данные экранов хранятся в Vuex: например, состав корзины и состояние её загрузки. Axios выполняет запросы к серверу, а vue-axios подключает его к приложению.
Так мы разделили ответственность. Мобильная часть показывает товары и передаёт действия клиента на сервер. Сервер рассчитывает корзину, оформляет заказ и возвращает данные, которые приложение показывает клиенту.
Покажу, как эти части взаимодействуют при загрузке корзины.
Например, здесь состояние корзины меняется только после получения ответа. Ошибка запроса не оставляет индикатор загрузки включённым:
import { createApp } from 'vue'
import { createStore } from 'vuex'
import { createRouter, createWebHistory } from 'vue-router'
import axios from 'axios'
import VueAxios from 'vue-axios'
import App from './App.vue'
import CartPage from './CartPage.vue'
const http = axios.create({ baseURL: '/example-api/' })
const store = createStore({
state: () => ({ cart: [], loading: false, error: '' }),
mutations: {
patch(state, values) { Object.assign(state, values) }
},
actions: {
async loadCart({ commit }) {
commit('patch', { loading: true, error: '' })
try {
const { data } = await http.get('cart')
commit('patch', { cart: data.items })
} catch {
commit('patch', { error: 'Не удалось загрузить корзину' })
} finally {
commit('patch', { loading: false })
}
}
}
})
const router = createRouter({
history: createWebHistory(),
routes: [{ path: '/cart', component: CartPage }]
})
createApp(App).use(store).use(router).use(VueAxios, http).mount('#app')
App — корневой компонент приложения, а CartPage отображает корзину и запускает её загрузку через store.dispatch('loadCart'). Сам состав заказа приходит с сервера, а Vuex даёт экрану единое место для чтения данных и состояния запроса.
Найти нужное и получить отклик на касание
На главной клиент может перейти к новинкам и акциям, в каталоге — выбирать по разделам или брендам. Для горизонтальных подборок мы использовали Swiper с компонентами для Vue. Он работает в баннерах главной и товарных блоках, в том числе в подборке похожих товаров.
Вместо длинного вертикального списка соседние позиции можно просмотреть свайпом. Минимальный пример такой подборки:
<script setup>
import { Swiper, SwiperSlide } from 'swiper/vue'
import 'swiper/css'
defineProps({ products: { type: Array, required: true } })
</script>
<template>
<Swiper :slides-per-view="1.4" :space-between="12">
<SwiperSlide v-for="product in products" :key="product.id">
<article>
<h3>{{ product.name }}</h3>
<p>{{ product.pack }}</p>
</article>
</SwiperSlide>
</Swiper>
</template>
Часть следующей карточки остаётся видимой и подсказывает, что подборку можно пролистать. Содержимое карточки при этом остаётся обычной разметкой Vue.
В списке товаров клиент открывает фильтры и сортировку. На сенсорном экране нет привычного наведения мыши, поэтому для части элементов мы добавили симуляцию hover: при касании появляется состояние нажатия, после отпускания оно снимается с небольшой задержкой. Кнопка отвечает на действие прямо под пальцем.
Это собственный приём интерфейса. Его можно выразить отдельным Vue-компонентом: обработчики принадлежат кнопке и появляются вместе с ней, даже если сама панель открылась позднее.
<script setup>
import { ref, onBeforeUnmount } from 'vue'
const pressed = ref(false)
let timer
function press() {
clearTimeout(timer)
pressed.value = true
}
function release() {
timer = setTimeout(() => { pressed.value = false }, 140)
}
function cancel() {
clearTimeout(timer)
pressed.value = false
}
onBeforeUnmount(cancel)
</script>
<template>
<button type="button" :class="{ pressed }"
@touchstart.passive="press" @touchend="release"
@touchcancel="cancel">
Фильтры
</button>
</template>
<style scoped>
button.pressed { background: #dce8ff; }
button:focus-visible { outline: 2px solid #24556c; }
</style>
Задержка сохраняет короткий отклик после отпускания. Отмена жеста сбрасывает подсветку, а удаление компонента очищает таймер. Такая подсветка сообщает о касании; ожидание серверного ответа показывается отдельно.
Не собирать одинаковые данные заново
За переходом к каталогу стоит работа сервера. Даже список разделов нужно подготовить для приложения. Для повторяющихся запросов мы используем кеширование в контроллерах «1С-Битрикс»: оно применяется, в частности, к разделам, брендам и баннерам главной.
Контроллер сначала проверяет сохранённый результат. Если тот ещё действителен, ответ можно вернуть сразу; иначе данные собираются и записываются в кеш. Для публичного списка разделов этот порядок выглядит так. $loadSections — функция, которая получает актуальный список из источника данных:
use Bitrix\Main\Data\Cache;
function publicSections(callable $loadSections): array
{
$cache = Cache::createInstance();
$ttl = 300;
$key = 'example-public-sections-v1';
$directory = '/example-case/sections';
if ($cache->initCache($ttl, $key, $directory)) {
return $cache->getVars();
}
if (!$cache->startDataCache()) {
return $loadSections();
}
try {
$sections = $loadSections();
$cache->endDataCache($sections);
return $sections;
} catch (\Throwable $error) {
$cache->abortDataCache();
throw $error;
}
}
При попадании в кеш функция загрузки вообще не вызывается. При ошибке незавершённая запись отменяется. Общий ключ подходит только для данных, одинаковых для всех получателей.
В приложении для части данных также используются теги — отметки зависимости кеша от исходного содержимого. Они позволяют связывать его сброс с изменениями данных. Выбор того, что можно сохранять и когда обновлять, здесь важнее самого вызова кеша: список разделов и персональный расчёт заказа требуют разного подхода.
От выбора товара — к данным компании и документам
Каталог можно просматривать без входа, а работа с кабинетом требует авторизации. В регистрации мы запрашиваем сведения оптового клиента: контактное лицо, заведение, город и ИНН.
Для телефона используется vue-3-mask: поле задаёт привычный формат номера. VeeValidate проверяет форму, а Yup описывает правила для полей. Например, маску телефона и проверку электронной почты можно связать так:
<script setup>
import { Form, Field, ErrorMessage } from 'vee-validate'
import { MaskInput } from 'vue-3-mask'
import * as yup from 'yup'
const emit = defineEmits(['submit'])
const schema = yup.object({
phone: yup.string().required('Укажите телефон'),
email: yup.string().required('Укажите почту').email('Проверьте адрес')
})
</script>
<template>
<Form :validation-schema="schema" @submit="emit('submit', $event)">
<Field name="phone" v-slot="{ value, handleChange, handleBlur }">
<label>Телефон
<MaskInput :model-value="value" mask="+7 (###) ###-##-##"
@update:model-value="handleChange" @blur="handleBlur" />
</label>
</Field>
<ErrorMessage name="phone" />
<label>Почта <Field name="email" type="email" /></label>
<ErrorMessage name="email" />
<button type="submit">Продолжить</button>
</Form>
</template>
Маска помогает вводить номер, а проверка показывает ошибку рядом с полем. В примере валидная форма передаёт значения родительскому компоненту; серверная проверка данных остаётся отдельным этапом.
После заказа клиент возвращается к прежним закупкам. В кабинете есть список заказов с фильтрами по периоду, статусу и адресу. Для выбора дат мы используем Vue Datepicker. Этот же компонент помогает запросить акт сверки: можно выбрать квартал либо указать другой период.
Для произвольного периода достаточно двух календарных полей. Здесь начальная дата ограничивает выбор конечной, а кнопка становится доступна после заполнения корректного интервала:
<script setup>
import { ref, computed } from 'vue'
import Datepicker from '@vuepic/vue-datepicker'
import '@vuepic/vue-datepicker/dist/main.css'
const emit = defineEmits(['request'])
const from = ref(null)
const to = ref(null)
const valid = computed(() => from.value && to.value && to.value >= from.value)
</script>
<template>
<form @submit.prevent="valid && emit('request', { from, to })">
<label>Начало периода
<Datepicker v-model="from" :enable-time-picker="false" />
</label>
<label>Конец периода
<Datepicker v-model="to" :min-date="from" :enable-time-picker="false" />
</label>
<button :disabled="!valid">Запросить акт</button>
</form>
</template>
Календарь здесь обслуживает конкретную рабочую задачу: клиент задаёт период документа. В обработчике запроса даты нужно привести к согласованному с сервером формату календарной даты, сохранив выбранный день.
Уведомление возвращает клиента к действию
Ещё один путь в приложение — push-уведомление. Для него мы используем Firebase Cloud Messaging и нативный плагин, который связывает получение сообщения с интерфейсом на Vue.
Firebase отвечает за доставку push, приложение — за дальнейшее действие. Когда клиент открывает уведомление из фонового состояния, обработчик может направить его на нужный экран через Vue Router. Так сообщение становится точкой продолжения работы.
Чтобы по уведомлению открыть список заказов, обработчик принимает заранее предусмотренное событие и выполняет переход:
// Вызывается после готовности нативной среды и настройки push.
export function connectPush(plugin, router, onError) {
plugin.onMessageReceived(message => {
if (message.tap !== 'background') return
if (message.destination !== 'order-list') return
router.push({ name: 'example-orders' }).catch(onError)
}, onError)
}
Здесь plugin — нативный Firebase-плагин, а example-orders — заранее зарегистрированный маршрут Vue Router. Обработка разрешений и регистрация устройства выполняются при настройке push. Сам переход короткий, потому что навигация уже объединяет экраны приложения.
Что получилось
Мы выпустили Craft Cartel для iOS и Android и объединили в приложении просмотр ассортимента, заказы, документы и расчёты. Клиент выбирает товары по параметрам закупки, работает со своим кабинетом и возвращается к нужному действию из уведомления.
В этом проекте технические решения связаны с повседневной работой оптового клиента. Свайп помогает просмотреть подборку, отклик на касание — взаимодействовать с фильтрами, календарь — задать период сверки. Общая навигация соединяет эти действия, а серверный кеш избавляет от повторной подготовки одинаковых ответов.
Для похожего приложения я бы начинал с пути клиента после выбора товара: какие заказы он должен видеть, какие документы запрашивать и куда возвращаться из уведомления. Ответы на эти вопросы определяют и состав экранов, и взаимодействие с сервером.
Приведённые примеры кода условны и написаны специально для этого кейса. Они иллюстрируют описанные приёмы и не взяты из исходного кода проекта.