Статьи

Создание вашего стартапа: Dashboard Foundation

Конечный продукт
Что вы будете создавать

Это руководство является частью серии « Создай свой стартап с помощью PHP» на Envato Tuts +. В этой серии я проведу вас через запуск стартапа от концепции до реальности, используя мое приложение Meeting Planner в качестве примера из реальной жизни. На каждом этапе я буду публиковать код Планировщика собраний в качестве примеров с открытым исходным кодом, из которых вы можете извлечь уроки. Я также буду решать вопросы, связанные с бизнесом по мере их возникновения.

Поскольку Meeting Planner близок к запуску альфа-версии, нам нужен способ обрабатывать запросы поддержки пользователей и отслеживать активность. Другими словами, нам нужно создать административную панель мониторинга с управлением пользователями и отчетами. В беседах с консультантом мы обсуждали, что, когда я обращаюсь к потенциальным инвесторам, мне нужны отличные данные, детализирующие поведение пользователей и рост сервиса.

В сегодняшнем эпизоде ​​мы создадим основу для нашей административной панели инструментов, а также возьмем на себя и создадим некоторые из первоначальных и исторических отчетов. Например, мы будем знать, сколько человек зарегистрировалось в любое время, сколько собраний было запланировано и какой процент приглашенных участников понравился сервисом, достаточным для организации собственной встречи. На самом деле было довольно интересно создавать такие вещи и просматривать данные, даже если мы их предварительно запустили.

Если вы еще не опробовали Планировщик собраний ( и хотите показать себя в сводных данных самостоятельно ), запланируйте свою первую встречу . Я принимаю участие в комментариях ниже, так что скажите мне, что вы думаете! Вы также можете связаться со мной в Twitter @reifman . Мне особенно интересно, если вы хотите предложить новые функции или темы для будущих уроков.

Напоминаем, что весь код для Meeting Planner написан на Yii2 Framework для PHP. Если вы хотите узнать больше о Yii2, ознакомьтесь с нашей параллельной серией Программирование с Yii2 .

Yii2 предлагает веб-сайты фронт-офиса и бэк-офиса в рамках расширенной настройки приложения Вы можете прочитать больше об этом в моем учебнике Envato Tuts +, « Как программировать с Yii2: Использование расширенного шаблона приложения» . По сути, интерфейсный сайт расширенного шаблона обеспечивает функциональность, ориентированную на людей, а фоновый сайт создан для панели инструментов службы и сайта администрирования.

Чтобы активировать его, мне просто нужно было настроить сайты Apache в моей локальной среде MAMP и на рабочем сервере Ubuntu. Например, вот конфигурация Apache на рабочем сервере для загрузки /backend/web сайта /backend/web :

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
<IfModule mod_ssl.c>
<VirtualHost *:443>
 ServerName your-administration-site.com
 DocumentRoot «/var/www/mp/backend/web»
<Directory «/var/www/mp/backend/web»>
    # use mod_rewrite for pretty URL support
    RewriteEngine on
    # If a directory or a file exists, use the request directly
    RewriteCond %{REQUEST_FILENAME} !-f
    RewriteCond %{REQUEST_FILENAME} !-d
    # Otherwise forward the request to index.php
    RewriteRule .
</Directory>
SSLCertificateFile /etc/letsencrypt/live/meetingplanner.io/cert.pem
SSLCertificateKeyFile /etc/letsencrypt/live/meetingplanner.io/privkey.pem
Include /etc/letsencrypt/options-ssl-apache.conf
SSLCertificateChainFile /etc/letsencrypt/live/meetingplanner.io/chain.pem
</VirtualHost>
</IfModule>

Затем я создал новый макет для внутреннего сайта на основе внешнего сайта, но с различными параметрами меню. Я решил, что домашняя страница будет перенаправлена ​​на страницу статистики в реальном времени. И меню будет предлагать ссылки на данные в реальном времени, данные вчерашнего дня в полночь и исторические данные. Я объясню немного больше, когда мы продолжим.

Панель планирования собрания - макет меню

Вот \ backend \ views \ layouts \ main.php с меню:

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
<body>
   <?php $this->beginBody() ?>
   <div class=»wrap»>
       <?php
           NavBar::begin([
               ‘brandLabel’ => Yii::t(‘backend’,’Meeting Planner’),
               ‘brandUrl’ => ‘https://meetingplanner.io’,
               ‘options’ => [
                   ‘class’ => ‘navbar-inverse navbar-fixed-top’,
               ],
           ]);
           $menuItems[] = [
                       ‘label’ => ‘Real Time’,
                       ‘items’ => [
                         [‘label’ => Yii::t(‘frontend’,’Usage’), ‘url’ => [‘/data/current’]],
                       ]
                     ];
           $menuItems[] = [
                       ‘label’ => ‘Yesterday’,
                       ‘items’ => [
                         [‘label’ => Yii::t(‘frontend’,’User Data’), ‘url’ => [‘/user-data’]],
                       ]
                     ];
             $menuItems[]=[
                       ‘label’ => ‘Historical’,
                       ‘items’ => [
                         [‘label’ => Yii::t(‘frontend’,’Statistics’), ‘url’ => [‘/historical-data’]],
                       ],
                     ];
           if (Yii::$app->user->isGuest) {
               $menuItems[] = [‘label’ => ‘Login’, ‘url’ => [‘/site/login’]];
           } else {
               $menuItems[] = [
                 ‘label’ => ‘Account’,
                 ‘items’ => [
                   [‘label’ => ‘Logout (‘ . Yii::$app->user->identity->username . ‘)’,
                   ‘url’ => [‘/site/logout’],
                   ‘linkOptions’ => [‘data-method’ => ‘post’],
                   ],
                 ],
               ];
           }
           echo Nav::widget([
               ‘options’ => [‘class’ => ‘navbar-nav navbar-right’],
               ‘items’ => $menuItems,
           ]);
           NavBar::end();
       ?>
 
       <div class=»container»>
       <?= Breadcrumbs::widget([
           ‘links’ => isset($this->params[‘breadcrumbs’]) ?
       ]) ?>
       <?= $content ?>
       </div>
   </div>

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

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

Страница данных в реальном времени должна отображать моментальный снимок того, что происходит на сайте. Изначально я хотел знать:

  • Сколько встреч в системе?
  • Сколько там пользователей?
  • Каков их статус?

Для этого я создал внутреннюю модель DataController.php и Data.php. Я также сделал шаг вперед и вместо того, чтобы создавать необработанный HTML в своем представлении, чтобы отобразить это, я создал ActiveDataProviders из своих запросов и передал их в виджеты сетки Yii; результат выглядит лучше, его проще строить и поддерживать.

Этот код запрашивает количество встреч в системе, сгруппированных по их статусу:

01
02
03
04
05
06
07
08
09
10
11
12
public static function getRealTimeData() {
   $data = new \stdClass();
 
   $data->meetings = new ActiveDataProvider([
     ‘query’ => Meeting::find()
     ->select([‘status,COUNT(*) AS dataCount’])
     //->where(‘approved = 1’)
     ->groupBy([‘status’]),
     ‘pagination’ => [
     ‘pageSize’ => 20,
     ],
     ]);

Этот код в /backend/views/data/current.php отображает его:

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
<?php
/* @var $this yii\web\View */
use yii\grid\GridView;
use common\models\User;
use frontend\models\Meeting;
$this->title = Yii::t(‘backend’,’Meeting Planner’);
?>
<div class=»site-index»>
  <div class=»body-content»>
 
        <h1>Real Time Data</h1>
          <h3>Meetings</h3>
 
        <?= GridView::widget([
            ‘dataProvider’ => $data->meetings,
            ‘columns’ => [
              [
                ‘label’=>’Status’,
                  ‘attribute’ => ‘status’,
                  ‘format’ => ‘raw’,
                  ‘value’ => function ($model) {
                    return ‘<div>’.Meeting::lookupStatus($model->status).'</div>’;
                      },
              ],
              ‘dataCount’,
            ],
        ]);

Выглядит это так (данных мало, так как сайт еще не запущен!) :

Панель планирования собрания - данные о собраниях в реальном времени

Затем я создал еще несколько запросов в реальном времени, а остальная часть страницы выглядит так:

Панель планирования встреч - данные и места в реальном времени

Что касается приведенных выше столбцов « Люди активны» и « Через приглашение» , если вы приглашаете человека на собрание, мы считаем его как пользователя с помощью приглашения, пока он не создаст пароль или не свяжет свою социальную учетную запись. До этого момента их единственный доступ к Планировщику собраний осуществляется через вашу ссылку для приглашения по электронной почте и идентификатор аутентификации.

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

Создание исторической отчетности для общесистемных действий оказалось немного более сложным. Я решил создать несколько зависимых слоев для сбора данных.

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

Верхний слой — это таблица HistoricalData, которая строит свои вычисления с использованием таблицы UserData прошлой ночью.

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

Я проведу вас через то, как я это сделал. Результат получился довольно неплохим.

Вот таблица миграции для UserData — она ​​содержит данные, которые я хотел вычислить ночью, чтобы помочь историческим вычислениям:

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
22
23
public function up()
   {
     $tableOptions = null;
     if ($this->db->driverName === ‘mysql’) {
         $tableOptions = ‘CHARACTER SET utf8 COLLATE utf8_unicode_ci ENGINE=InnoDB’;
     }
 
       $this->createTable(‘{{%user_data}}’, [
           ‘id’ => Schema::TYPE_PK,
           ‘user_id’ => Schema::TYPE_BIGINT.’
           ‘is_social’ => Schema::TYPE_SMALLINT.’
           ‘invite_then_own’ => Schema::TYPE_SMALLINT.’
           ‘count_meetings’ => Schema::TYPE_INTEGER.’
           ‘count_meetings_last30′ => Schema::TYPE_INTEGER.’
           ‘count_meeting_participant’ => Schema::TYPE_INTEGER.’
           ‘count_meeting_participant_last30′ => Schema::TYPE_INTEGER.’
           ‘count_places’ => Schema::TYPE_INTEGER.’
           ‘count_friends’ => Schema::TYPE_INTEGER.’
           ‘created_at’ => Schema::TYPE_INTEGER .
           ‘updated_at’ => Schema::TYPE_INTEGER .
       ], $tableOptions);
       $this->addForeignKey(‘fk_user_data_user_id’, ‘{{%user_data}}’, ‘user_id’, ‘{{%user}}’, ‘id’, ‘CASCADE’, ‘CASCADE’);
   }

Например, count_meeting_participant_last30 — это количество встреч, на которые этот человек был приглашен за последние 30 дней.

Вот миграция таблицы для HistoricalData почти все столбцы в этой таблице должны быть рассчитаны на разных уровнях данных:

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
public function up()
 {
   $tableOptions = null;
   if ($this->db->driverName === ‘mysql’) {
       $tableOptions = ‘CHARACTER SET utf8 COLLATE utf8_unicode_ci ENGINE=InnoDB’;
   }
 
     $this->createTable(‘{{%historical_data}}’, [
         ‘id’ => Schema::TYPE_PK,
         ‘date’=> Schema::TYPE_INTEGER.’
         ‘percent_own_meeting’ => Schema::TYPE_FLOAT.’
         ‘percent_own_meeting_last30′ => Schema::TYPE_FLOAT.’
         // % of users invited by others who own a meeting
         ‘percent_invited_own_meeting’ => Schema::TYPE_FLOAT.’
         ‘percent_participant’ => Schema::TYPE_FLOAT.’
         ‘percent_participant_last30′ => Schema::TYPE_FLOAT.’
         ‘count_users’ => Schema::TYPE_INTEGER.’
         ‘count_meetings_completed’ => Schema::TYPE_INTEGER.’
         ‘count_meetings_planning’ => Schema::TYPE_INTEGER.’
         ‘count_places’ => Schema::TYPE_INTEGER.’
         ‘average_meetings’ => Schema::TYPE_FLOAT.’
         ‘average_friends’ => Schema::TYPE_FLOAT.’
         ‘average_places’ => Schema::TYPE_FLOAT.’
         ‘source_google’ => Schema::TYPE_INTEGER.’
         ‘source_facebook’ => Schema::TYPE_INTEGER.’
         ‘source_linkedin’ => Schema::TYPE_INTEGER.’
     ], $tableOptions);

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

Все миграции находятся в / console / migrations. Вот как это выглядит при запуске миграции базы данных.

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
$ ./yii migrate/up
Yii Migration Tool (based on Yii v2.0.8)
 
Total 2 new migrations to be applied:
    m160609_045838_create_user_data_table
    m160609_051532_create_historical_data_table
 
Apply the above migrations?
*** applying m160609_045838_create_user_data_table
    > create table {{%user_data}} … done (time: 0.003s)
    > add foreign key fk_user_data_user_id: {{%user_data}} (user_id) references {{%user}} (id) … done (time: 0.004s)
*** applied m160609_045838_create_user_data_table (time: 0.013s)
 
*** applying m160609_051532_create_historical_data_table
    > create table {{%historical_data}} … done (time: 0.003s)
*** applied m160609_051532_create_historical_data_table (time: 0.005s)
 
 
2 migrations were applied.
 
Migrated up successfully.

Каждую ночь после полуночи фоновая задача будет вычислять статистику предыдущей ночи. Вот метод фона:

1
2
3
4
5
6
public function actionOvernight() {
     $since = mktime(0, 0, 0);
     $after = mktime(0, 0, 0, 2, 15, 2016);
     UserData::calculate(false,$after);
     HistoricalData::calculate(false,$after);
   }

Я создал задачу cron для запуска actionOvernight в 1:15 ежедневно. Примечание: когда вы сосредоточены на программировании стартапа днем ​​и ночью, задание cron — это все, что вы получите за одну ночь.

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

01
02
03
04
05
06
07
08
09
10
11
12
public static function recalc() {
   UserData::reset();
   HistoricalData::reset();
   $after = mktime(0, 0, 0, 2, 15, 2016);
   $since = mktime(0, 0, 0, 4, 1, 2016);
   while ($since < time()) {
     UserData::calculate($since,$after);
     HistoricalData::calculate($since,$after);
     // increment a day
     $since+=24*60*60;
   }
 }

Примечание. Временной интервал — это обходной путь, позволяющий исключить некоторых ранних пользователей, которые зарегистрировались до того, как могли запланировать собрание. Я хотел, чтобы исторические данные отражали более точное описание недавней активности (в настоящее время есть несколько сотен старых учетных записей без какой-либо активности). Я, вероятно, уберу это позже.

Вот код, который заполняет таблицу UserData каждую ночь:

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
public static function calculate($since=false,$after = 0) {
     if ($since===false) {
       $since = mktime(0, 0, 0);
     }
     $monthago = $since-(60*60*24*30);
     $all = User::find()->where(‘created_at>’.$after)->andWhere(‘created_at<‘.$since)->all();
     foreach ($all as $u) {
       // create new record for user or update old one
       $ud = UserData::find()->where([‘user_id’=>$u->id])->one();
       if (is_null($ud)) {
         $ud = new UserData();
         $ud->user_id = $u->id;
         $ud->save();
       }
       $user_id = $u->id;
       // count meetings they’ve organized
       $ud->count_meetings = Meeting::find()->where([‘owner_id’=>$user_id])->andWhere(‘created_at<‘.$since)->count();
       $ud->count_meetings_last30 = Meeting::find()->where([‘owner_id’=>$user_id])->andWhere(‘created_at<‘.$since)->andWhere(‘created_at>=’.$monthago)->count();
       // count meetings they were invited to
       $ud->count_meeting_participant = Participant::find()->where([‘participant_id’=>$user_id])->andWhere(‘created_at<‘.$since)->count();
       $ud->count_meeting_participant_last30 = Participant::find()->where([‘participant_id’=>$user_id])->andWhere(‘created_at<‘.$since)->andWhere(‘created_at>=’.$monthago)->count();
       // count places and Friends
       $ud->count_places = UserPlace::find()->where([‘user_id’=>$user_id])->andWhere(‘created_at<‘.$since)->count();
       $ud->count_friends = Friend::find()->where([‘user_id’=>$user_id])->andWhere(‘created_at<‘.$since)->count();
       // calculate invite than Own — participant first, then organizer
       $first_invite = Participant::find()->where([‘participant_id’=>$user_id])->andWhere(‘created_at<‘.$since)->orderby(‘created_at asc’)->one();
       $first_organized = Meeting::find()->where([‘owner_id’=>$user_id])->andWhere(‘created_at<‘.$since)->orderby(‘created_at asc’)->one();
       $ud->invite_then_own =0;
       if (!is_null($first_invite) && !is_null($first_organized)) {
         if ($first_invite->created_at < $first_organized->created_at && $first_organized->created_at < $since) {
           // they were invited as a participant earlier than they organized their own meeting
           $ud->invite_then_own =1;
         }
       }
       if (Auth::find()->where([‘user_id’=>$user_id])->count()>0) {
         $ud->is_social =1;
       } else {
         $ud->is_social =0;
       }
       $ud->update();
     }
   }

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

Вот код, который определяет, решил ли этот пользователь запланировать встречу, используя службу после приглашения:

1
2
3
4
5
6
7
$ud->invite_then_own =0;
 if (!is_null($first_invite) && !is_null($first_organized)) {
    if ($first_invite->created_at < $first_organized->created_at && $first_organized->created_at < $since) {
       // they were invited as a participant earlier than they organized their own meeting
       $ud->invite_then_own =1;
     }
}

Вот код, который использует UserData для заполнения HistoricalData :

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
public static function calculate($since = false,$after=0) {
       if ($since === false) {
         $since = mktime(0, 0, 0);
       }
       // create new record for date or update existing
       $hd = HistoricalData::find()->where([‘date’=>$since])->one();
       if (is_null($hd)) {
         $hd = new HistoricalData();
         $hd->date = $since;
         $action = ‘save’;
       } else {
         $action = ‘update’;
       }
       // calculate $count_meetings_completed
       $hd->count_meetings_completed = Meeting::find()->where([‘status’=>Meeting::STATUS_COMPLETED])->andWhere(‘created_at<‘.$since)->count();;
       // calculate $count_meetings_planning
       $hd->count_meetings_planning = Meeting::find()->where(‘status<‘.Meeting::STATUS_COMPLETED)->andWhere(‘created_at<‘.$since)->count();;
       // calculate $count_places
       $hd->count_places = Place::find()->where(‘created_at>’.$after)->andWhere(‘created_at<‘.$since)->count();
       // calculate $source_google
       $hd->source_google = Auth::find()->where([‘source’=>’google’])->count();
       // calculate $source_facebook
       $hd->source_facebook = Auth::find()->where([‘source’=>’facebook’])->count();
       // calculate $source_linkedin
       $hd->source_linkedin = Auth::find()->where([‘source’=>’linkedin’])->count();
       // total users
       $total_users = UserData::find()->count();
       // calculate $count_users
       $hd->count_users = $total_users;
       //User::find()->where(‘status<>’.User::STATUS_DELETED)->andWhere(‘created_at>’.$after)->count();
       $total_friends = Friend::find()->where(‘created_at>’.$after)->andWhere(‘created_at<‘.$since)->count();
       $total_places = Place::find()->where(‘created_at>’.$after)->andWhere(‘created_at<‘.$since)->count();
       if ($total_users >0) {
         $hd->average_meetings = ($hd->count_meetings_completed+$hd->count_meetings_planning)/$total_users;
         $hd->average_friends = $total_friends/$total_users;
         $hd->average_places = $total_places/$total_users;
         $hd->percent_own_meeting = UserData::find()->where(‘count_meetings>0’)->count() / $total_users;
         $hd->percent_own_meeting_last30 = UserData::find()->where(‘count_meetings_last30>0’)->count() / $total_users;
         $hd->percent_participant = UserData::find()->where(‘count_meeting_participant>0’)->count() / $total_users;
         $hd->percent_participant_last30 = UserData::find()->where(‘count_meeting_participant_last30>0’)->count() / $total_users;
         $query = (new \yii\db\Query())->from(‘user_data’);
         $sum = $query->sum(‘invite_then_own’);
         $hd->percent_invited_own_meeting=$sum/$total_users;
       }
       if ($action==’save’) {
         $hd->save();
       } else {
         $hd->update();
       }
   }

Он суммирует итоги и вычисляет проценты и средние.

Вот как выглядит готовый продукт:

Панель планирования собрания - окончательные исторические данные

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

Кстати, процент приглашенных пользователей, планирующих свои собственные собрания, составляет около 9% (но это небольшой набор данных).

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

Панель планирования встреч - Игра престолов Мелисандр

Если ты больше не слышишь от меня, знай, что Господь Света нашел мне применение.

Как уже упоминалось, я в настоящее время лихорадочно работаю над подготовкой Meeting Planner к альфа-версии. В первую очередь я сосредоточен на ключевых улучшениях и функциях, которые сделают альфа-релиз гладким.

Сейчас я отслеживаю все в Асане , о котором напишу в следующем уроке; это было невероятно полезно. Есть также некоторые интересные новые функции, все еще в пути. (Как учитель йоги, я думаю, что Асана — худшее название продукта за всю историю. Они в основном приняли в йоге общий термин «асана» или «ах-сана» и изменили произношение на «сауна» — и включили это в свои вступительные видео. В прошлом году было нелегко консультироваться с членами команды клиента о том, что они кладут в сауну, и разговаривать с йогами об асане. Но я отвлекся.)

Я также начинаю уделять больше внимания предстоящим усилиям по сбору инвестиций в Meeting Planner. Я только начинаю экспериментировать с WeFunder, основываясь на внедрении новых правил краудфандинга SEC . Пожалуйста, обратите внимание на наш профиль . Я также напишу больше об этом в следующем уроке.

Опять же, пока вы ждете больше эпизодов, запланируйте свою первую встречу и опробуйте шаблоны с друзьями в почтовых ящиках Gmail. Кроме того, я буду признателен, если вы поделитесь своим опытом ниже в комментариях, и мне всегда интересны ваши предложения. Вы также можете связаться со мной через Twitter @reifman напрямую. Вы также можете разместить их на сайте поддержки Meeting Planner .

Следите за будущими уроками в серии « Построение стартапа с помощью PHP» .