[Из песочницы] Миллион за месяц: как запустить стартап в Европе своими силами

Всем привет!

Два месяца назад я и мой знакомый (для краткости, назовем его Илья) запустили свой стартап.
Пффф… Скажите вы. Каждый день кто-то что-то запускает. Кто-то запускает в одиночку. Некоторые кучкуются в команды. У кого-то есть деньги на разработку\маркетинг, кто-то предлагает долю, пост-оплату, опционы. Все крутятся как могут и ищут партнеров также.

fi8ayt5fc0g2zt4l7ws_dd4luzk.jpeg


У нас не было денег, был лишь опыт и 2 недели до первых продаж.

Под катом я расскажу о том, с чем мы столкнулись и как заработали миллион в кризис
Мне лишь хочется поделиться тем опытом, который приобрели мы.

Точнее поделиться тем опытом, который позволил запустить это все за 2 недели.

Серьезно! Я не шучу. От первого митинга до запуска проекта — 2 недели!

Неделя на разработку и раскрутку в соц. сетях и неделя на продажи.

В ходе повествования я буду упускать малозначимые (или очевидные, которые можно найти в документациях) детали и делать акцент лишь на определенных тонкостях. Также будут встречаться конфиги и примеры кода.

Кто мы?


Когда начался карантин многие сферы бизнеса начали потихоньку проседать. Это затронуло и сферу Ильи, а именно — «танцы и хореография». Он владеет большим количеством танцевальных школ. Такая же беда настигла и фитнес. И там люди начали выкручиваться путем проведения онлайн тренировок. Так у него появилась идея провести танцевальный лагерь онлайн (по полной аналогии с оффлайн лагерями). С чем он и пришел ко мне.

В конечном итоге команда сформировалась только из 2х людей:

  • техническая часть (я)
  • маркетинг + управление (Илья)


Если не лукавить, то этот список можно дополнить наемными сотрудниками (дизайнер, смм, переводчик, юрист), но это уже мелочи жизни. Мы это относим к разовым расходам. И эти расходы небольшие.

Что мы делали?


Смысл танцевального лагеря был прост. Есть 12 преподавателей и мероприятие из 3х дней. По 4 класса в день. Необходимо было создать платформу, где пользователь смог бы зарегистрироваться, купить себе доступ в лагерь. Была также информация о преподавателях, лайнап\расписание, некая форма для обратной связи. Ну и самое интересное — необходимо было организовать стриминг артистов, кто также сидит дома на карантине. Почему мы не выбрали решения типа instagram — бан за фоновую музыку. Решения типа Zoom отмели за ненужностью интерактива + слишком дорого + необходимость установки софта.

Отдельный нюанс в том, что преподаватели из Европы и США также находятся на карантине. Это накладывало определенные ограничения на техническую часть.

И еще один нюанс — это должен быть законченный продукт. Люди должны были видеть нечто красивое и понимать, что они покупают, что они получат. Почему им это нужно.

Чего мы хотели?


Мы хотели сделать MVP для запуска первого лагеря и в дальнейшем проводить их еще и еще. Нужно проверить гипотезу, что у людей есть потребность в данном сервисе. И дело не только в карантине. Как взрослые люди мы предъявляли ряд требований к нему:

  1. Решение должно быть стабильным. Никаких 500-ых ошибок, зависших транзакций. Тикеты в саппорт нам не нужны. Все должно работать без нашего участия
  2. Решение должно быть гибким и масштабируемым. Сегодня у нас 100 человек. Завтра 10000. Письма должны ходить. Стриминг должен выдерживать любую нагрузку. В случае смены платежного шлюза например, не должно быть временного лага в принятии оплаты
  3. Интерфейс должен быть понятен как для пользователей, так и преподаватели сидя дома должны суметь организовать стриминг с мобильного телефона
  4. Мы хотели получить прибыль и в дальнейшем масштабировать наши доходы. Т.о. мы по максимуму минимизировали расходы


Что мы использовали?


  • backend на symfony
  • frontend на jquery + vue
  • упаковка ресурсов через webpack
  • очередь nats
  • небольшой микросервис на Go для отправки писем
  • хранилище Minio для статического контента
  • redis для различных подзадач


Панель администратора была сделана с использованием Sonata Admin Bundle. Настраивается этот зверь быстро, удобен в использовании. Не надо тратить время на разработку какой-либо кастомной панели.

Теперь остановимся чуточку подробнее на разных нюансах.

Сразу хочется отметить, что описанные ниже вещи это лишь список рекомендаций (советов), как сделать быстро и недорого. Многое из написанного для кого-то будет очевидным, а для кого-то нет.

Авторизация


Авторизация через SMS это самый большой пылесос для ваших денег. Если вы запускаете стартап — подумайте 10 раз о том, нужно ли оно вам. Представьте, что к вам пришли 1000 пользователей. А теперь умножьте на среднюю цену SMS в Европе\США и добавьте это в себестоимость вашего проекта. Также вы испытаете ряд проблем по согласовании имени отправителя. Да к тому же это еще и долго (в некоторых странах этот процесс длится от 2–3х недель и более). А от стандартного имени отправителя многие операторы сотовой связи блокируют SMS. Мало приятного. Поэтому мы использовали авторизацию через почту\соц. сети.

Очень быстро авторизацию через соц.сети можно интегрировать, добавив в проект библиотеку LexikJWTAuthenticationBundle.

Я более чем уверен, что в других языках и фреймворках также имеются подобные.

Она хорошо документирована и есть различные примеры.


Письма мы сразу стали отсылать через очередь. Я считаю, что даже для маленьких проектов это нужно взять за основу. Делается это очень быстро. Да и кто знает, может через месяц ваш стартап выстрелит и там будет х10 пользователей.

Про NATS есть статьи на хабре в том числе.

Есть готовые библиотеки как для php, так и для Go (и множества других языков)
Т.е. получилась связка PHP → NATS → GO → Amazon SES

При помощи docker-compose устанавливаем NATS:

version: "2"

services:
 nats:
  container_name: nats-mq
  image: nats
  networks:
    - "back"
  ports:
    - "127.0.0.1:4222:4222"
    - "127.0.0.1:6222:6222"
    - "127.0.0.1:8222:8222"
  entrypoint: "/gnatsd -c gnatsd.conf --auth SECRET --debug"
  restart: always

networks:
  back:
    driver: "bridge"


Добавляем в composer.json пакет для работы с ним:

...
"repejota/nats": "^0.8.6"
...

Вот пример функции для отправки почты:

public function email($emails, $body, $subject)
{
    if (empty($emails)) {
        return false;
    }

    $prefix = getenv('NATS_PREFIX');
    $encoder = new JSONEncoder();
    $options = new ConnectionOptions([
        'token' => getenv('NATS_TOKEN')
    ]);
    $client = new EncodedConnection($options, $encoder);

    try {
        $client->connect();
        $client->publish(
            $prefix . 'email',
            [
                'Emails' => $emails,
                'HtmlBody' => $body,
                'TextBody' => strip_tags($body),
                'Subject' => $subject,
                'Sender' => 'info@' . getenv('DOMAIN_NAME')
            ]
        );
    } catch (\Exception $exception) {
        return false;
    }

    return true;
}


У себя в проектах я взял за привычку писать множество фабричных методов, и вызывать функцию отправки уже из суперкласса уведомлений.

Это добавляет гибкости. А ведь стартап должен придерживаться этого при разработке.
Еще это удобно для отладки.

Ведь можно добавить некий класс Dummy с функцией:

public function email($emails, $body, $subject)
{
    $time = time();
    file_put_contents(SRC_PATH . '../var/dummy/email' . $time . '.html', $body);
    return true;
}


Общий интерфейс оставлю здесь
interface NotificationInterface
{

    /**
     * Инициализируем нотификатор
     *
     * init storage
     * @void
     */
    public function init();

    /**
     * Синхронные ли уведомления
     *
     * @return bool
     */
    public function isSync();

    /**
     * Отправляет СМС
     *
     * @param $phone
     * @param $message
     * @return bool
     */
    public function sms($phone, $message);

    /**
     * Отправляет письмо
     *
     * @param $email
     * @param $body
     * @param $subject
     * @return bool
     */
    public function email($email, $body, $subject);

}

В Go подписаться на очередь можно примерно так:

opts := nats.Options{
	Url:   ...,
	Token: ...,
}

nc, err := opts.Connect()
if err != nil {
	log.Error("error in nats connection", err)
}

c, err := nats.NewEncodedConn(nc, nats.JSON_ENCODER)
if err != nil {
	log.Error("error in nats encoding", err)
}

defer c.Close()
defer nc.Close()

c.Subscribe("email", func(p *EmailInput) {
	p.sendEmail()
})


объявив перед этим:

type EmailInput struct {
	Emails   []*string `json:"Emails"`
	HtmlBody string    `json:"HtmlBody"`
	TextBody string    `json:"TextBody"`
	Subject  string    `json:"Subject"`
	Sender   string    `json:"Sender"`
}

func (email *EmailInput) sendEmail() {
	...
}


Для отправки, как я писал выше, мы использовали Amazon SES.

Почему?

  • администрировать свой почтовый сервер очень накладно и это отнимет время
  • это готовое решение с хорошим бесплатным лимитом
  • легко настраиваются DKIM + SPF (иначе письма будут улетать в спам, конверсия будет падать)


Еще один очень важный нюанс.

Так как у нас не было отдельного отдела поддержки, то мы сразу добавили переадресацию с почты поддержки на наши личные. Это позволило оперативно реагировать на вопросы пользователей.

Теперь ваши пользователи могут БЫСТРО и ЛЕГКО авторизоваться. Самое время получить с них заветные денежки за билеты на ваше мероприятие.

Биллинг


С точки зрения кода система платежей устроена довольно-таки просто. Есть суперкласс для генерации ссылки, и опять же множество фабричных методов. Они умеют сгенерировать ссылку для оплаты, и обработать колбэк. А также запустить пост-продажные хуки (уведомление по почте + добавление доступа к мероприятию).

Общие интерфейсы для хуков и платежных систем оставляю здесь
interface HookInterface
{

    /**
     * @return string
     */
    public function getName();

    /**
     * @param Transaction $transaction
     * @param array $params
     */
    public function run(Transaction $transaction, array $params);

}
interface SystemInterface
{

    /**
     * @return string
     */
    public function getName();

    /**
     * @param Transaction $transaction
     * @param string|null $redirect
     * @return null
     */
    public function createLink(Transaction $transaction, $redirect = null);

    /**
     * @param Transaction $transaction
     * @return string
     */
    public function createWidget(Transaction $transaction);

    /**
     * @return string
     */
    public function createWidgetAssets();

    /**
     * @param $request
     * @param $em
     * @return PayResponse
     */
    public function handleCallback(Request $request, ObjectManager $em);

}


Продукт наш был ориентирован на Европу, поэтому и цены были в Евро.

С какими трудностями же мы столкнулись:

  1. Можно по пальцам пересчитать платежные шлюзы, которые имеют валютные терминалы. Что это? Это такая штука, которая позволяет принимать оплату именно в ЕВРО, а не рублях. Почему это так важно? Да потому что Европейцы настороженно относятся к Русским, очень много мошенников. Рубли при оплате будут снижать конверсию и добавлять тикеты в саппорт, где вам прийдется объяснять кто вы и что вы.
  2. Согласование валютного терминала занимает время. Готовьтесь, что это будет от 5 дней и выше. На практике около 10. Потому что вас тщательно проверят, да и в карантин Европейцы работали неохотно. Также вопросы возникнут и со стороны нашего отечественного банка, зачем вам это потребовалось
  3. 3d secure никто не отменял (SMS, которая приходит с кодом подтверждения оплаты). Поэтому если у клиента в Европе банк не поддерживает эту возможность, то оплатить он УВЫ не сможет. На этот случай прийдется билить клиента ручками на PayPal
  4. Согласование сайта происходит только тогда, когда весь контент уже присутствует на нем. Т.е. сайт полностью готов, в том числе все юридический документы типа оферты. Т.к. у нас был не интернет-магазин и в целом нетипичное решение — мы обратились к юристам для составления оферты под наш продукт и последующий её перевод


Так получилось, что мы сначала принимали оплаты в рублях. После согласования платежного шлюза — переключились на евро. Это немного снизило конверсию на старте.

Организация стриминга


Если с авторизацией все просто, да и биллинг можно своими силами прикрутить. Такие задачи часто встречаются у средне-статистического разработчика. То вот со стримингом все не так однозначно, как кажется на первый взгляд. Что же не так?

  1. Мы никогда не имели с ним дело. Нужен был сервер + клиент для преподавателей
  2. Картинку необходимо зеркалить, т.е. делать вертикальное отображение (особенности танцевальных видео)
  3. Мы точно не знали сколько будет участников в лагере. И покупать где-либо (в готовых стриминговых решениях) аккаунт на месяц\год\кол-во трафика мы пока не могли. Это сожрало бы всю прибыль
  4. Нужна была защита iframe от воровства контента хотя бы по рефереру


В качестве решения мы выбрали связку larix broadcastr app → nginx + rtmp → cdn → client

В ходе поиска, приложение Larix Broadcaster для вещания RTMP с мобильных устройств оказалось самым надежным и стабильным. На отдельном маленьком виртуальном сервере мы подняли nginx + rtmp для рестриминга дальше в CDN.

Данная схема является самой дешевой. Намного дешевле, чем пользоваться услугами готовых стриминговых платформ. У вас нет необходимости покупать какой либо премиум\голд\супер аккаунт на месяц\год. Вы платите только за потраченный трафик. Это было идеальным решением в нашей ситуации.

Там же и разворачивали зеркально картинку.

Конфигурация для рестриминга лежит здесь
worker_processes  1;

events {
    worker_connections  1024;
}

http {
    include       mime.types;
    default_type  application/octet-stream;

    sendfile        on;
    
    keepalive_timeout  65;
    
    include _root.conf;
    include _stat.conf;
}
include _rtmp.conf;
server {

    listen       80;
    server_name  localhost;
    location / {
        root   html;
        index  index.html index.htm;
    }

    error_page   500 502 503 504  /50x.html;
    location = /50x.html {
        root   html;
    }
}
server {
    listen      8080;

    auth_basic "Restricted Area";
    auth_basic_user_file .htpasswd;

    location / {
        root www;
    }

    location /stat {
        rtmp_stat all;
        rtmp_stat_stylesheet stat.xsl;
    }

    location /stat.xsl {
        root www;
    }

}
rtmp {
    server {

        listen 1935;
        chunk_size 4096;

        application your-custom-hash-here {
            live on;
            record off;

            allow publish all;
            allow play all;

            push rtmp://localhost/cdn;
        }

        application cdn {
            allow publish 127.0.0.1;
            deny publish all;

            live on;
            record off;

            exec ffmpeg -i rtmp://localhost/cdn/$name -vf "hflip" -crf 30 -preset ultrafast -acodec aac -strict experimental -ar 44100 -ac 2 -b:a 128k -vcodec libx264 -x264-params keyint=60:no-scenecut=1 -r 30 -b:v 2000k -f flv rtmp://rtmp.cdnhost.abc;
        }

    }
}


На фронт части мы же использовали простой html5 плеер, сконфигурированный с учетом мобильных устройств и их особенностей:

videojs($('#video')[0], {
    controls: true,
    autoplay: false,
    preload: 'auto',
    poster: '/static/splash-en.jpg',
    language: 'en',
    muted: true,
    html5: {
        hls: {
            overrideNative: !videojs.browser.IS_SAFARI
        }
    }
});


Решение оказалось очень стабильным и удобным.

После мероприятия мы подсчитали процент жалоб на качество — это были 2%. И в основном люди, у которых старые стационарные компьютеры или устаревшее ПО (браузеры).

Заключение


Что же в итоге?

Мы провели 2 онлайн лагеря.

Получили только положительный фидбэк от первых пользователей.

В тяжелое время карантина это было именно то, что нужно людям дома.

Общая выручка составила более 1.000.000 рублей.

Если немножечко поговорить о кастдеве, то ориентируясь на фидбэк первого лагеря, мы доработали систему

  1. Сделали возможность покупки билета на один день (а не только полный билет) — это подняло выручку, т.к. многие не хотели покупать полный билет, а хотели купить лишь мастер-класс одного или двух преподавателей
  2. Сделали простой чат около видео-плеера на socket.io — это добавило интерактива в уроки и позволяло получить обратную связь в реальном времени
  3. Т.к. лагерь международный и многие люди путались в часовых поясах — мы добавили возможность изменять таймзону в расписании и смотреть расписание по своему часовому поясу


Какие выводы можно сделать:

  1. Не обязательно иметь стартовые инвестиции и огромную команду, чтобы проверить идею
  2. Ведите разработку гибко, прислушивайтесь к вашим пользователям. Добавляйте только то, что реально нужно людям
  3. Не пишите сразу огромный монолит с кучей функций, он не нужен для проверки гипотезы. Не копите тех. долг. Умейте находить баланс между «плохим» кодом, и «избыточной» функциональностью. Используйте каждый инструмент по назначению
  4. Используйте опыт своих знакомых. Большинство задач ведь уже решено. Достаточно лишь найти нужные инструменты, провести исследования, объединить их в единую систему


Надеюсь, что данная статья кому-нибудь покажется полезной, а кого-нибудь мотивирует.

© Habrahabr.ru