Статьи

Создание вашего запуска: расширенные команды планирования

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

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

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

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

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

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

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

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

Сначала давайте посмотрим, как расширить существующую панель навигации. Вот список функций, которые я планировал добавить:

  • Возможность изменения мест, дат и времени встречи после ее завершения.
  • Требование небольших изменений в собрании, например, можем ли мы встретиться на час раньше? Или в другом предложенном месте?
  • Пересмотр встречи полностью с теми же участниками и местами.
  • Повторение собрания с использованием участников, мест, дней недели и времени суток из предыдущего собрания, чтобы упростить планирование нового собрания.
  • Показаны участникам хронологическую историю всех мероприятий по планированию встречи.
  • Просмотр и обновление настроек встречи.

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

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

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

Затем я просмотрел документацию Bootstrap и обнаружил выпадающий список:

Создайте свой стартап Расширенное планирование - Документация по начальной загрузке выпадающего меню

Мне понравилось, как это работает. Поэтому я решил поместить большинство расширенных команд в выпадающую кнопку слева.

Вот пример того, как это выглядит на этапе планирования собрания:

Создайте свой стартап Расширенное планирование - выпадающий список кнопки Планировщик собраний

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

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
22
23
/* the partial position knows to set the dropclass variable up or down */
echo $this->render(‘_command_bar_past’, [
    ‘model’=>$model,
    ‘isPast’=>true,
    ‘dropclass’=>’dropup’,
    ‘isOwner’ => $isOwner,
]);
 
/* the resulting view applies the dropclass */
<div class=»command-bar»>
  <div class=»row»>
    <div class=»col-xs-4″>
      <div class=»<?= $dropclass ?>» >
      <button class=»btn btn-default dropdown-toggle» type=»button» id=»dropdownMenu1″ data-toggle=»dropdown» aria-haspopup=»true» aria-expanded=»true»>
      <?= Yii::t(‘frontend’,’Options’);?>
      <span class=»caret»>
      </button>
      <ul class=»dropdown-menu» aria-labelledby=»dropdownMenu1″>
        <?php
          if (!$isPast && ($model->viewer == Meeting::VIEWER_ORGANIZER || $meetingSettings->participant_reopen)) {
            ?>
            <li><?= Html::a(Yii::t(‘frontend’, ‘Make changes’), [‘reopen’,’id’=>$model->id],
             [‘title’=>Yii::t(‘frontend’,’tbd’)]);

Я также создал файлы частичного просмотра, которые будут отображаться в зависимости от статуса собрания. Например, в /frontend/views/meeting/view_confirmed.php вы можете увидеть, что _command_bar_past.php _command_bar_confirmed.php _command_bar_past.php или _command_bar_confirmed.php :

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
<?php
   if ( $model->status >= $model::STATUS_COMPLETED) {
     …
     echo $this->render(‘_command_bar_past’, [
         ‘model’=>$model,
         ‘isPast’=>true,
         ‘dropclass’=>’dropdown’,
         ‘isOwner’ => $isOwner,
     ]);
   } else {
     echo $this->render(‘_command_bar_confirmed’, [
         ‘model’=>$model,
         ‘meetingSettings’ => $meetingSettings,
         ‘showRunningLate’=>$showRunningLate,
         ‘isPast’=>$isPast,
         ‘dropclass’=>’dropdown’,
         ‘isOwner’ => $isOwner,
     ]);
   }
 ?>

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

Вот пример определения, отображать ли возможность повторно открыть собрание и внести в него изменения, как если бы он все еще находился на этапе планирования:

1
2
3
4
5
6
7
<ul class=»dropdown-menu» aria-labelledby=»dropdownMenu1″>
 <?php
   if (!$isPast && ($model->viewer == Meeting::VIEWER_ORGANIZER
      ||
  ?>
     <li><?= Html::a(Yii::t(‘frontend’, ‘Make changes’), [‘reopen’,’id’=>$model->id],
         [‘title’=>Yii::t(‘frontend’,’tbd’)]);

Этот параметр включается в раскрывающееся меню Bootstrap, если собрание не прошло дату начала, а просмотрщик является организатором или организатор разрешил участникам вносить изменения.

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

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

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

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

В целом, все, что я делаю, занимает немного больше времени, чем в режиме быстрого прототипирования.

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

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

Один из простых способов внесения изменений в совещание, которое было завершено, — просто позволить организатору повторно открыть его, вернув его на этап планирования. Затем они могут добавить дату и время, выбрать новые и снова завершить собрание.

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

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

Когда собрание создано, его настройки инициализируются с настройками пользователя по умолчанию:

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
public function initializeMeetingSetting($meeting_id,$owner_id) {
  $checkMtgStg = MeetingSetting::find()->where([‘meeting_id’ => $meeting_id])->one();
  if (is_null($checkMtgStg)) {
    // load meeting creator (owner) user settings to initialize meeting_settings
    UserSetting::initialize($owner_id);
    $user_setting = UserSetting::find()->where([‘user_id’ => $owner_id])->one();
    $meeting_setting = new MeetingSetting();
    $meeting_setting->meeting_id = $meeting_id;
    $meeting_setting->participant_add_place=$user_setting->participant_add_place;
    $meeting_setting->participant_add_date_time=$user_setting->participant_add_date_time;
    $meeting_setting->participant_choose_place=$user_setting->participant_choose_place;
    $meeting_setting->participant_choose_date_time=$user_setting->participant_choose_date_time;
    $meeting_setting->participant_finalize=$user_setting->participant_finalize;
    $meeting_setting->participant_reopen=$user_setting->participant_reopen;
    $meeting_setting->participant_request_change=$user_setting->participant_request_change;
    $meeting_setting->save();
  }
}

Вы можете увидеть результаты нового контроллера и вид обновления для MeetingSettings ниже:

Создайте свой стартап Расширенное планирование - настройки собрания

Вот /frontend/controllers/MeetingController.php’s actionReopen() :

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
public function actionReopen($id) {
  $m = $this->findModel($id);
  $m->setViewer();
  // also check reopen()
  if ($m->viewer == Meeting::VIEWER_ORGANIZER || $m->meetingSettings->participant_reopen) {
    if ($m->reopen()) {
        Yii::$app->getSession()->setFlash(‘success’, Yii::t(‘frontend’,’The meeting has now been reopened so you can make changes.’));
    } else {
        Yii::$app->getSession()->setFlash(‘error’, Yii::t(‘frontend’,’Sorry, you are not allowed to reopen a meeting this many times. Try creating a new meeting.’));
    }
  } else {
    Yii::$app->getSession()->setFlash(‘error’, Yii::t(‘frontend’,’Sorry, you are not allowed to do this.’));
  }
  return $this->redirect([‘view’, ‘id’ => $id]);
}

А вот код модели Meeting.php, чтобы перевести собрание обратно в режим планирования:

01
02
03
04
05
06
07
08
09
10
11
12
13
public function reopen() {
  // when organizer or participant with permission asks to make changes
  if (MeetingLog::withinActionLimit($this->id,MeetingLog::ACTION_REOPEN,Yii::$app->user->getId(),7)) {
    $this->status = Meeting::STATUS_SENT;
    $this->update();
    $this->increaseSequence();
    MeetingLog::add($this->id,MeetingLog::ACTION_REOPEN,Yii::$app->user->getId());
    return true;
  } else {
    // over limit per meeting
    return false;
  }
}

withinActionLimit проверяет, сколько раз кто-то пытается открыть собрание. IncreaseSequence для файла .ics — поскольку дата, время и место встречи меняются, необходимо указать файл ics.

На изображении ниже показана встреча, которая была подтверждена множеством расширенных функций:

Создайте свой стартап Расширенное планирование - Панель команд с полностью загруженным выпадающим меню

Когда пользователь нажимает кнопку « Внести изменения» в вышеприведенном меню, статус собрания возвращается к планированию, и он может вернуться, чтобы обновить дату, время и место:

Создайте свое старое расширенное планирование - возобновленное планирование встреч

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

В настоящее время я ограничиваю эту функцию организаторами (не участниками), но могу расширить ее позже. Метод Meeting.php::Reschedule() поддерживает любого человека, выполняющего действие:

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
57
58
59
60
61
62
63
64
65
66
67
68
69
public function reschedule() {
     $newOwner = $user_id = Yii::$app->user->getId();
     // user can only cancel their own Meeting
     if ($this->owner_id == $user_id) {
       $addParticipant = false;
       $this->cancel($user_id);
       MeetingLog::add($this->id,MeetingLog::ACTION_RESCHEDULE,$user_id);
     } else {
         // if user is participant — needs to reverse
         if (!isAttendee($this->id,$user_id)) {
           // user isn’t owner or participant — error
           return false;
         } else {
           // reverse the owner and participant
           $addParticipant = $this->owner_id;
         }
     }
     // create new meeting — as copy of old meeting
     $m = new Meeting();
     $m->attributes = $this->attributes;
     $m->owner_id = $newOwner;
     $m->status = Meeting::STATUS_PLANNING;
     $m->created_at = time();
     $m->updated_at = time();
     $m->logged_at = 0;
     $m->cleared_at = 0;
     $m->sequence_id = 0;
     $m->save();
     // clone the selected place (not all of them)
     $chosenPlace = $this->getChosenPlace($this->id);
     if ($chosenPlace!==false) {
       $mp = new MeetingPlace;
       $mp->suggested_by = $newOwner;
       $mp->attributes = $chosenPlace->attributes;
       $mp->meeting_id = $m->id;
       $mp->created_at = time();
       $mp->updated_at = time();
       $mp->save();
     }
     // clone the participants
     foreach ($this->participants as $p) {
       // skip if reschedule new owner was a participant
       if ($p->participant_id==$user_id) {
         continue;
       }
       // note Participant afterSave will create choices for place
       $clone_p = new Participant();
       $clone_p->attributes = $p->attributes;
       $clone_p->email = User::findOne($p->participant_id)->email;
       $clone_p->meeting_id = $m->id;
       $clone_p->invited_by = $newOwner;
       $clone_p->status = Participant::STATUS_DEFAULT;
       $clone_p->created_at = time();
       $clone_p->updated_at = time();
       $clone_p->save();
     }
     // if participant asked to reschedule — not yet allowed
     if ($addParticipant!==false) {
       $newP = new Participant();
       $newP->meeting_id = $m->id;
       $newP->participant_id = $addParticipant;
       $newP->invited_by = $user_id;
       $newP->status = Participant::STATUS_DEFAULT;
       $newP->created_at = time();
       $newP->updated_at = time();
       $newP->save();
     }
     return $m->id;
   }

Участники и выбранное место клонируются в новом собрании.

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

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
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
public function repeat() {
     // to do — expand repeat meeting to have more options
     // eg pick same day and time in future week or two
     // eg duplicate chosenplace or all places
     // eg duplicate all participants or just some (complicated if participant duplicates)
     $newOwner = $user_id = Yii::$app->user->getId();
     // if user is participant — needs to reverse
     if ($this->owner_id == $user_id) {
       $addParticipant = false;
     } else {
       if (!isAttendee($this->id,$user_id)) {
         // user isn’t owner or participant — error
         return false;
       } else {
         // reverse the owner and participant
         $addParticipant = $this->owner_id;
       }
     }
     // create new meeting — as copy of old meeting
     $m = new Meeting();
     $m->attributes = $this->attributes;
     $m->owner_id = $newOwner;
     $m->status = Meeting::STATUS_PLANNING;
     $m->created_at = time();
     $m->updated_at = time();
     $m->logged_at = 0;
     $m->cleared_at = 0;
     $m->sequence_id = 0;
     $m->save();
     // get prior meetings selected time and create two future times for the next two weeks
     $chosenTime=$this->getChosenTime($this->id);
     $mt1 = MeetingTime::createTimePlus($m->id,$m->owner_id,$chosenTime->start,$chosenTime->duration);
     $mt2 = MeetingTime::createTimePlus($m->id,$m->owner_id,$mt1->start,$chosenTime->duration);
     // clone the selected place (not all of them)
     $chosenPlace = $this->getChosenPlace($this->id);
     if ($chosenPlace!==false) {
       $mp = new MeetingPlace;
       $mp->suggested_by = $newOwner;
       $mp->attributes = $chosenPlace->attributes;
       $mp->meeting_id = $m->id;
       $mp->created_at = time();
       $mp->updated_at = time();
       $mp->save();
     }
     // clone the participants
     foreach ($this->participants as $p) {
       // skip if reschedule new owner was a participant
       if ($p->participant_id==$user_id) {
         continue;
       }
       // note Participant afterSave will create choices for place
       $clone_p = new Participant();
       $clone_p->attributes = $p->attributes;
       $clone_p->email = User::findOne($p->participant_id)->email;
       $clone_p->meeting_id = $m->id;
       $clone_p->status = Participant::STATUS_DEFAULT;
       $clone_p->created_at = time();
       $clone_p->updated_at = time();
       $clone_p->save();
     }
     // if participant asked to repeat
     // add the prior owner as a participant
     if ($addParticipant!==false) {
       $newP = new Participant();
       $newP->meeting_id = $m->id;
       $newP->participant_id = $addParticipant;
       $newP->invited_by = $user_id;
       $newP->status = Participant::STATUS_DEFAULT;
       $newP->created_at = time();
       $newP->updated_at = time();
       $newP->save();
     }
     MeetingLog::add($this->id,MeetingLog::ACTION_REPEAT,$user_id,0);
     return $m->id;
   }

MeetingTime::createTimePlus() ниже добавляет время встречи в тот же день недели и время дня, но в будущем на одну неделю, даже если исходная встреча произошла несколько месяцев назад. Цикл while был необходим для старых встреч.

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
public static function createTimePlus($meeting_id,$suggested_by,$start,$duration,$timeInFuture = 604800) {
     // finds time in multiples of a week or timeInFuture seconds ahead past the present time
     $newStart = $start+$timeInFuture;
     while ($newStart<time()) {
       $newStart+=$timeInFuture;
     }
     $mt = new MeetingTime();
     $mt->meeting_id = $meeting_id;
     $mt->start = $newStart;
     $mt->duration = $duration;
     $mt->end = $mt->start+($mt->duration*3600);
     $mt->suggested_by = $suggested_by;
     $mt->status = MeetingTime::STATUS_SUGGESTED;
     $mt->updated_at = $mt->created_at = time();
     $mt->save();
     return $mt;
   }

Я также создал функцию повторной отправки на случай, если участник не получит свое первоначальное приглашение или окончательное подтверждение.

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
22
23
public static function resend($id) {
     $sender_id = Yii::$app->user->getId();
     // check if within resend limit
     $cnt = MeetingLog::find()
       ->where([‘actor_id’=>$sender_id])
       ->andWhere([‘meeting_id’=>$id])
       ->andWhere([‘action’=>MeetingLog::ACTION_RESEND])
       ->count();
     if ($cnt >= Meeting::RESEND_LIMIT ) {
       return false;
     } else {
       $m = Meeting::findOne($id);
       if ($m->status == Meeting::STATUS_SENT) {
         $m->send($sender_id,true);
         // resend the planning invitation
       } else if ($m->status == Meeting::STATUS_CONFIRMED) {
         // resend the confirmed invitation
         $m->finalize($sender_id,true);
       }
       MeetingLog::add($id,MeetingLog::ACTION_RESEND,$sender_id,0);
       return true;
     }
   }

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

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

Создайте свой стартап Расширенное планирование - просмотр истории событий

MeetingLogController.php проверяет, что зритель является участником собрания, и подготавливает данные для просмотра журнала:

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
public function actionView($id)
   {
     if (!Meeting::isAttendee($id,Yii::$app->user->getId())) {
       $this->redirect([‘site/authfailure’]);
     }
     $timezone = MiscHelpers::fetchUserTimezone(Yii::$app->user->getId());
     Yii::$app->timeZone = $timezone;
           $searchModel = new MeetingLogSearch();
     $dataProvider = $searchModel->search([‘MeetingLogSearch’=>[‘meeting_id’=>$id]]);
     $m= Meeting::findOne($id);
     return $this->render(‘index’, [
         ‘searchModel’ => $searchModel,
         ‘dataProvider’ => $dataProvider,
         ‘meeting_id’ => $id,
         ‘subject’ => $m->getMeetingHeader(‘log’),
         ‘timezone’ => $timezone,
     ]);
   }

Затем /frontend/views/meeting-log/index.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
<?php Pjax::begin();
<div class=»meeting-log-index»>
    <h1><?php echo Html::encode($this->title) ?></h1>
 
    <?php echo GridView::widget([
    ‘dataProvider’ => $dataProvider,
    //’filterModel’ => $searchModel,
    ‘columns’ => [
        [
          ‘label’=>’Actor’,
            ‘attribute’ => ‘actor_id’,
            ‘format’ => ‘raw’,
            ‘value’ => function ($model) {
                    return ‘<div>’.MiscHelpers::getDisplayName($model->actor_id).'</div>’;
                },
        ],
        [
          ‘label’=>’Action’,
            ‘attribute’ => ‘action’,
            ‘format’ => ‘raw’,
            ‘value’ => function ($model) {
                  return ‘<div>’.$model->getMeetingLogCommand().'</div>’;
                },
        ],
        [
          ‘label’=>’Item’,
            ‘attribute’ => ‘item_id’,
            ‘format’ => ‘raw’,
            ‘value’ => function ($model) {
                        return ‘<div>’.$model->getMeetingLogItem().'</div>’;
                },
        ],
        [
          ‘label’=>’Created’,
            ‘attribute’ => ‘created_at’,
            ‘format’ => ‘raw’,
            ‘value’ => function ($model) {
                        return ‘<div>’.Yii::$app->formatter->asDatetime($model->created_at,»hh:ss MMM d»).'</div>’;
                },
        ],
    ],
]);
?>
<?= Html::a(Yii::t(‘frontend’, ‘Return to Meeting’), [‘meeting/view’, ‘id’ => $meeting_id],
 [‘class’ => ‘btn btn-primary btn-info’,
 ‘title’=>Yii::t(‘frontend’,’Return to meeting page’),
]);
<?php Pjax::end();

Сейчас я нахожусь на интенсивном спринте кода, чтобы завершить бета-релиз. Боги редакции Envato Tuts + старались изо всех сил, чтобы отвлечь меня роботами, управляющими сознанием, и звуками, напоминающими OKCupid, из их приложения для работы на iOS, но они потерпели неудачу. Разработка Meeting Planner продолжается быстрыми темпами.

Решение социальной инженерии и UX для участников собрания, чтобы запросить и отреагировать на небольшие изменения в расписании, может сделать или разрушить бренд Meeting Planner, и это то, над чем я работаю больше всего.

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

Следите за всеми этими и другими учебниками, ознакомившись с серией « Построение стартапа на PHP» .