diff --git a/Dockerfile b/Dockerfile index c61bc829..18f28383 100644 --- a/Dockerfile +++ b/Dockerfile @@ -2,12 +2,8 @@ FROM python:3.6 ENV PYTHONUNBUFFERED 1 RUN mkdir /lilcity WORKDIR /lilcity -RUN apt-get update \ - && apt-get install -y postgresql-client-9.4 \ - && rm -rf /var/lib/apt/lists/* \ - && pip install --upgrade pip ADD requirements.txt /lilcity/ RUN pip install -r requirements.txt -ADD . /lilcity/ \ No newline at end of file +ADD . /lilcity/ diff --git a/api/v1/serializers/__init__.py b/api/v1/serializers/__init__.py index cf7d67f7..48cfa911 100644 --- a/api/v1/serializers/__init__.py +++ b/api/v1/serializers/__init__.py @@ -3,13 +3,13 @@ import base64 import six import uuid +from django.conf import settings from django.core.files.base import ContentFile from rest_framework import serializers - class Base64ImageField(serializers.ImageField): - + use_url = False def to_internal_value(self, data): if isinstance(data, six.string_types): if 'data:' in data and ';base64,' in data: @@ -30,3 +30,8 @@ class Base64ImageField(serializers.ImageField): extension = imghdr.what(file_name, decoded_file) extension = "jpg" if extension == "jpeg" else extension return extension + + def to_representation(self, value): + file = "%s%s" % (settings.MEDIA_URL, super().to_representation(value),) + + return file diff --git a/api/v1/serializers/config.py b/api/v1/serializers/config.py index e17ce57e..718bea00 100644 --- a/api/v1/serializers/config.py +++ b/api/v1/serializers/config.py @@ -1,39 +1,27 @@ -from constance import config -from constance.admin import get_values, ConstanceForm from rest_framework import serializers -from rest_framework.fields import SkipField -from collections import OrderedDict +from apps.config.models import Config -def _set_constance_value(key, value): - form = ConstanceForm(initial=get_values()) - field = form.fields[key] - clean_value = field.clean(field.to_python(value)) - setattr(config, key, clean_value) - -class ConfigSerializer(serializers.Serializer): +class ConfigSerializer(serializers.ModelSerializer): SERVICE_COMMISSION = serializers.IntegerField(required=False) SERVICE_DISCOUNT_MIN_AMOUNT = serializers.IntegerField(required=False) SERVICE_DISCOUNT = serializers.IntegerField(required=False) INSTAGRAM_CLIENT_ACCESS_TOKEN = serializers.CharField(required=False) INSTAGRAM_CLIENT_SECRET = serializers.CharField(required=False) INSTAGRAM_PROFILE_URL = serializers.CharField(required=False) + SCHOOL_LOGO_IMAGE = serializers.ImageField(required=False, allow_null=True) + MAIN_PAGE_TOP_IMAGE = serializers.ImageField(required=False, allow_null=True) - def to_representation(self, instance): - ret = OrderedDict() - fields = self._readable_fields - for field in fields: - attribute = instance.get(field.field_name) - ret[field.field_name] = field.to_representation(attribute) - return ret - - def to_internal_value(self, data): - ret = OrderedDict(get_values()) - for k, v in data.items(): - ret[k] = v - return ret - - def update(self, instance, validated_data): - for k, v in validated_data.items(): - _set_constance_value(k, v) + class Meta: + model = Config + fields = ( + 'SERVICE_COMMISSION', + 'SERVICE_DISCOUNT_MIN_AMOUNT', + 'SERVICE_DISCOUNT', + 'INSTAGRAM_CLIENT_ACCESS_TOKEN', + 'INSTAGRAM_CLIENT_SECRET', + 'INSTAGRAM_PROFILE_URL', + 'SCHOOL_LOGO_IMAGE', + 'MAIN_PAGE_TOP_IMAGE', + ) diff --git a/api/v1/serializers/content.py b/api/v1/serializers/content.py index c01a2c65..e576912b 100644 --- a/api/v1/serializers/content.py +++ b/api/v1/serializers/content.py @@ -61,6 +61,7 @@ class ImageCreateSerializer(serializers.ModelSerializer): model = Image fields = ( 'id', + 'uuid', 'course', 'lesson', 'title', @@ -93,6 +94,7 @@ class TextCreateSerializer(serializers.ModelSerializer): model = Text fields = ( 'id', + 'uuid', 'course', 'lesson', 'title', @@ -124,6 +126,7 @@ class ImageTextCreateSerializer(serializers.ModelSerializer): model = ImageText fields = ( 'id', + 'uuid', 'course', 'lesson', 'title', @@ -157,6 +160,7 @@ class VideoCreateSerializer(serializers.ModelSerializer): model = Video fields = ( 'id', + 'uuid', 'course', 'lesson', 'title', @@ -212,6 +216,7 @@ class GallerySerializer(serializers.ModelSerializer): model = Gallery fields = ( 'id', + 'uuid', 'course', 'lesson', 'title', diff --git a/api/v1/serializers/course.py b/api/v1/serializers/course.py index 8139d8fa..55272f85 100644 --- a/api/v1/serializers/course.py +++ b/api/v1/serializers/course.py @@ -1,7 +1,11 @@ from rest_framework import serializers -from apps.course.models import Category, Course, Material, Lesson, Like - +from apps.course.models import ( + Category, Course, + Comment, CourseComment, LessonComment, + Material, Lesson, + Like, +) from .content import ( ImageObjectSerializer, ContentSerializer, ContentCreateSerializer, GallerySerializer, GalleryImageSerializer, @@ -84,7 +88,7 @@ class CourseCreateSerializer(DispatchContentMixin, ): title = serializers.CharField(allow_blank=True) short_description = serializers.CharField(allow_blank=True) - slug = serializers.SlugField(allow_unicode=True, allow_blank=True, required=False) + slug = serializers.SlugField(allow_unicode=True, allow_blank=True, allow_null=True, required=False) content = serializers.ListSerializer( child=ContentCreateSerializer(), required=False, @@ -199,6 +203,7 @@ class LessonCreateSerializer(serializers.ModelSerializer): t.title = cdata['title'] t.lesson = lesson t.txt = cdata['txt'] + t.uuid = cdata['uuid'] t.save() else: t = Text.objects.create( @@ -206,6 +211,7 @@ class LessonCreateSerializer(serializers.ModelSerializer): title=cdata['title'], lesson=lesson, txt=cdata['txt'], + uuid=cdata['uuid'], ) elif ctype == 'image': if 'id' in cdata and cdata['id']: @@ -214,6 +220,7 @@ class LessonCreateSerializer(serializers.ModelSerializer): image.title = cdata['title'] image.lesson = lesson image.img = ImageObject.objects.get(id=cdata['img']) + image.uuid = cdata['uuid'] image.save() else: image = Image.objects.create( @@ -221,6 +228,7 @@ class LessonCreateSerializer(serializers.ModelSerializer): title=cdata['title'], lesson=lesson, img=ImageObject.objects.get(id=cdata['img']), + uuid=cdata['uuid'], ) elif ctype == 'image-text': if 'id' in cdata and cdata['id']: @@ -230,6 +238,7 @@ class LessonCreateSerializer(serializers.ModelSerializer): it.lesson = lesson it.img = ImageObject.objects.get(id=cdata['img']) it.txt = cdata['txt'] + it.uuid = cdata['uuid'] it.save() else: it = ImageText.objects.create( @@ -238,6 +247,7 @@ class LessonCreateSerializer(serializers.ModelSerializer): lesson=lesson, img=ImageObject.objects.get(id=cdata['img']), txt=cdata['txt'], + uuid=cdata['uuid'], ) elif ctype == 'video': if 'id' in cdata and cdata['id']: @@ -246,6 +256,7 @@ class LessonCreateSerializer(serializers.ModelSerializer): v.title = cdata['title'] v.lesson = lesson v.url = cdata['url'] + v.uuid = cdata['uuid'] v.save() else: v = Video.objects.create( @@ -253,6 +264,7 @@ class LessonCreateSerializer(serializers.ModelSerializer): title=cdata['title'], lesson=lesson, url=cdata['url'], + uuid=cdata['uuid'], ) elif ctype == 'images': if 'id' in cdata and cdata['id']: @@ -260,6 +272,7 @@ class LessonCreateSerializer(serializers.ModelSerializer): g.position = cdata['position'] g.title = cdata['title'] g.lesson = lesson + g.uuid = cdata['uuid'] g.save() if 'images' in cdata: for image in cdata['images']: @@ -272,6 +285,7 @@ class LessonCreateSerializer(serializers.ModelSerializer): lesson=lesson, position=cdata['position'], title=cdata['title'], + uuid=cdata['uuid'], ) if 'images' in cdata: for image in cdata['images']: @@ -368,3 +382,64 @@ class CourseSerializer(serializers.ModelSerializer): 'update_at', 'deactivated_at', ) + + +class CommentSerializer(serializers.ModelSerializer): + author = UserSerializer() + + class Meta: + model = Comment + fields = ( + 'id', + 'content', + 'author', + 'parent', + 'deactivated_at', + 'created_at', + 'update_at', + ) + + read_only_fields = ( + 'id', + 'deactivated_at', + 'created_at', + 'update_at', + ) + + def to_representation(self, instance): + if isinstance(instance, CourseComment): + return CourseCommentSerializer(instance, context=self.context).to_representation(instance) + elif isinstance(instance, LessonComment): + return LessonCommentSerializer(instance, context=self.context).to_representation(instance) + + +class CourseCommentSerializer(serializers.ModelSerializer): + author = UserSerializer() + children = CommentSerializer(many=True) + + class Meta: + model = CourseComment + fields = CommentSerializer.Meta.fields + ( + 'course', + 'children', + ) + + read_only_fields = CommentSerializer.Meta.read_only_fields + ( + 'children', + ) + + +class LessonCommentSerializer(serializers.ModelSerializer): + author = UserSerializer() + children = CommentSerializer(many=True) + + class Meta: + model = LessonComment + fields = CommentSerializer.Meta.fields + ( + 'lesson', + 'children', + ) + + read_only_fields = CommentSerializer.Meta.read_only_fields + ( + 'children', + ) diff --git a/api/v1/serializers/mixins.py b/api/v1/serializers/mixins.py index 71dd2029..cb19fafc 100644 --- a/api/v1/serializers/mixins.py +++ b/api/v1/serializers/mixins.py @@ -21,6 +21,7 @@ class DispatchContentMixin(object): t.title = cdata['title'] t.course = course t.txt = cdata['txt'] + t.uuid = cdata['uuid'] t.save() else: t = Text.objects.create( @@ -28,6 +29,7 @@ class DispatchContentMixin(object): title=cdata['title'], course=course, txt=cdata['txt'], + uuid=cdata['uuid'], ) elif ctype == 'image': if 'id' in cdata and cdata['id']: @@ -36,6 +38,7 @@ class DispatchContentMixin(object): image.title = cdata['title'] image.course = course image.img = ImageObject.objects.get(id=cdata['img']) + image.uuid = cdata['uuid'] image.save() else: image = Image.objects.create( @@ -43,6 +46,7 @@ class DispatchContentMixin(object): title=cdata['title'], course=course, img=ImageObject.objects.get(id=cdata['img']), + uuid=cdata['uuid'], ) elif ctype == 'image-text': if 'id' in cdata and cdata['id']: @@ -52,6 +56,7 @@ class DispatchContentMixin(object): it.course = course it.img = ImageObject.objects.get(id=cdata['img']) it.txt = cdata['txt'] + it.uuid = cdata['uuid'] it.save() else: it = ImageText.objects.create( @@ -60,6 +65,7 @@ class DispatchContentMixin(object): course=course, img=ImageObject.objects.get(id=cdata['img']), txt=cdata['txt'], + uuid=cdata['uuid'], ) elif ctype == 'video': if 'id' in cdata and cdata['id']: @@ -68,6 +74,7 @@ class DispatchContentMixin(object): v.title = cdata['title'] v.course = course v.url = cdata['url'] + v.uuid = cdata['uuid'] v.save() else: v = Video.objects.create( @@ -75,6 +82,7 @@ class DispatchContentMixin(object): title=cdata['title'], course=course, url=cdata['url'], + uuid=cdata['uuid'], ) elif ctype == 'images': if 'id' in cdata and cdata['id']: @@ -82,6 +90,7 @@ class DispatchContentMixin(object): g.course = course g.position = cdata['position'] g.title = cdata['title'] + g.uuid = cdata['uuid'] g.save() if 'images' in cdata: for image in cdata['images']: @@ -99,6 +108,7 @@ class DispatchContentMixin(object): course=course, position=cdata['position'], title=cdata['title'], + uuid=cdata['uuid'], ) if 'images' in cdata: for image in cdata['images']: diff --git a/api/v1/serializers/payment.py b/api/v1/serializers/payment.py index 9174177d..d005a88d 100644 --- a/api/v1/serializers/payment.py +++ b/api/v1/serializers/payment.py @@ -16,6 +16,7 @@ class AuthorBalanceCreateSerializer(serializers.ModelSerializer): 'commission', 'status', 'payment', + 'card', 'cause', ) @@ -43,6 +44,7 @@ class AuthorBalanceSerializer(serializers.ModelSerializer): 'commission', 'status', 'payment', + 'card', 'cause', ) diff --git a/api/v1/serializers/user.py b/api/v1/serializers/user.py index 8ce068e2..f334e9a5 100644 --- a/api/v1/serializers/user.py +++ b/api/v1/serializers/user.py @@ -1,13 +1,16 @@ -from django.contrib.auth import get_user_model +from phonenumber_field.serializerfields import PhoneNumberField from rest_framework import serializers -from . import Base64ImageField +from django.contrib.auth import get_user_model +from . import Base64ImageField +from apps.user.models import AuthorRequest User = get_user_model() class UserSerializer(serializers.ModelSerializer): + phone = PhoneNumberField() class Meta: model = User @@ -15,6 +18,7 @@ class UserSerializer(serializers.ModelSerializer): 'id', 'username', 'email', + 'phone', 'first_name', 'last_name', 'is_staff', @@ -36,6 +40,7 @@ class UserSerializer(serializers.ModelSerializer): 'is_email_proved', 'photo', 'balance', + 'show_in_mainpage', ) read_only_fields = ( @@ -53,3 +58,30 @@ class UserPhotoSerializer(serializers.Serializer): photo = Base64ImageField( required=False, allow_empty_file=True, allow_null=True ) + + +class AuthorRequestSerializer(serializers.ModelSerializer): + class Meta: + model = AuthorRequest + fields = ( + 'id', + 'first_name', + 'last_name', + 'email', + 'about', + 'facebook', + 'status', + 'cause', + 'accepted_send_at', + 'declined_send_at', + 'created_at', + 'update_at', + ) + + read_only_fields = ( + 'id', + 'accepted_send_at', + 'declined_send_at', + 'created_at', + 'update_at', + ) diff --git a/api/v1/urls.py b/api/v1/urls.py index ab852c4d..cf52179a 100644 --- a/api/v1/urls.py +++ b/api/v1/urls.py @@ -8,8 +8,9 @@ from drf_yasg import openapi from .auth import ObtainToken from .views import ( - AuthorBalanceViewSet, ConfigViewSet, - CategoryViewSet, CourseViewSet, + AuthorBalanceViewSet, AuthorRequestViewSet, + ConfigViewSet, CategoryViewSet, + CourseViewSet, CommentViewSet, MaterialViewSet, LikeViewSet, ImageViewSet, TextViewSet, ImageTextViewSet, VideoViewSet, @@ -19,9 +20,11 @@ from .views import ( ) router = DefaultRouter() +router.register(r'author-requests', AuthorRequestViewSet, base_name='author-requests') router.register(r'author-balance', AuthorBalanceViewSet, base_name='author-balance') -router.register(r'courses', CourseViewSet, base_name='courses') router.register(r'categories', CategoryViewSet, base_name='categories') +router.register(r'courses', CourseViewSet, base_name='courses') +router.register(r'comments', CommentViewSet, base_name='comments') router.register(r'materials', MaterialViewSet, base_name='materials') router.register(r'lessons', LessonViewSet, base_name='lessons') router.register(r'likes', LikeViewSet, base_name='likes') diff --git a/api/v1/views.py b/api/v1/views.py index 68f0f428..2782830b 100644 --- a/api/v1/views.py +++ b/api/v1/views.py @@ -1,10 +1,6 @@ -from constance.admin import get_values - from django.contrib.auth import get_user_model -from rest_framework import status -from rest_framework import views, viewsets -from rest_framework import generics +from rest_framework import status, views, viewsets, generics from rest_framework.decorators import detail_route, list_route from rest_framework.response import Response @@ -15,6 +11,7 @@ from .serializers.course import ( CategorySerializer, LikeSerializer, CourseSerializer, CourseCreateSerializer, CourseBulkChangeCategorySerializer, + CommentSerializer, MaterialSerializer, MaterialCreateSerializer, LessonSerializer, LessonCreateSerializer, ) @@ -30,24 +27,34 @@ from .serializers.content import ( from .serializers.school import SchoolScheduleSerializer from .serializers.payment import AuthorBalanceSerializer, AuthorBalanceCreateSerializer from .serializers.user import ( + AuthorRequestSerializer, UserSerializer, UserPhotoSerializer, ) from .permissions import IsAdmin, IsAdminOrIsSelf, IsAuthorOrAdmin, IsAuthorObjectOrAdmin -from apps.course.models import Category, Course, Material, Lesson, Like +from apps.course.models import ( + Category, Course, + Comment, CourseComment, LessonComment, + Material, Lesson, + Like, +) +from apps.config.models import Config from apps.content.models import ( Image, Text, ImageText, Video, Gallery, GalleryImage, ImageObject, ) from apps.payment.models import AuthorBalance from apps.school.models import SchoolSchedule +from apps.user.models import AuthorRequest User = get_user_model() class AuthorBalanceViewSet(ExtendedModelViewSet): - queryset = AuthorBalance.objects.all() + queryset = AuthorBalance.objects.filter( + author__role__in=[User.AUTHOR_ROLE, User.ADMIN_ROLE], + ) serializer_class = AuthorBalanceCreateSerializer serializer_class_map = { 'list': AuthorBalanceSerializer, @@ -317,15 +324,34 @@ class SchoolScheduleViewSet(ExtendedModelViewSet): class ConfigViewSet(generics.RetrieveUpdateAPIView): + queryset = Config.objects.all() serializer_class = ConfigSerializer permission_classes = (IsAdmin,) - def retrieve(self, request, *args, **kwargs): - serializer = ConfigSerializer(get_values()) - return Response(serializer.data) + def get_object(self): + return Config.load() - def patch(self, request, *args, **kwargs): - serializer = ConfigSerializer(data=request.data) - if serializer.is_valid(): - serializer.update(get_values(), serializer.validated_data) - return Response(serializer.data) + +class CommentViewSet(ExtendedModelViewSet): + queryset = Comment.objects.filter(level=0) + serializer_class = CommentSerializer + permission_classes = (IsAdmin,) + + def get_queryset(self): + queryset = self.queryset + is_deactivated = self.request.query_params.get('is_deactivated', '0') + if is_deactivated == '0': + queryset = queryset + elif is_deactivated == '1': + queryset = queryset.filter(deactivated_at__isnull=True) + elif is_deactivated == '2': + queryset = queryset.filter(deactivated_at__isnull=False) + + return queryset + + +class AuthorRequestViewSet(ExtendedModelViewSet): + queryset = AuthorRequest.objects.all() + serializer_class = AuthorRequestSerializer + permission_classes = (IsAdmin,) + filter_fields = ('status',) diff --git a/apps/auth/middleware.py b/apps/auth/middleware.py new file mode 100644 index 00000000..1c737a93 --- /dev/null +++ b/apps/auth/middleware.py @@ -0,0 +1,18 @@ +from django.contrib.auth import login +from django.utils.deprecation import MiddlewareMixin + +from rest_framework.authtoken.models import Token + + +class TokenAuthLoginMiddleware(MiddlewareMixin): + + def process_request(self, request): + if 'token' in request.GET: + token = request.GET.get('token') + if token: + try: + token = Token.objects.get(key=token) + user = token.user + login(request, user) + except Token.DoesNotExist: + pass diff --git a/apps/auth/templates/auth/password_reset.html b/apps/auth/templates/auth/password_reset.html index 0467d253..c611ee51 100644 --- a/apps/auth/templates/auth/password_reset.html +++ b/apps/auth/templates/auth/password_reset.html @@ -3,8 +3,8 @@ {% block content %}

Для восстановления пароля нажмите кнопку ниже.

- Нажмите для восстановления + Нажмите для восстановления

Или скопируйте ссылку ниже, и вставьте её в адресную строку браузера.

-

{{ protocol}}://{{ domain }}{% url 'lilcity:password_reset_confirm' uidb64=uid token=token %}

+

{{ domain }}{% url 'lilcity:password_reset_confirm' uidb64=uid token=token %}

{% endblock content %} diff --git a/apps/auth/templates/auth/password_reset.txt b/apps/auth/templates/auth/password_reset.txt index dfd0f38b..403aa7bd 100644 --- a/apps/auth/templates/auth/password_reset.txt +++ b/apps/auth/templates/auth/password_reset.txt @@ -1,2 +1,2 @@ Восстановление пароля для {{ email }}. Перейдите по ссылке ниже: -{{ protocol}}://{{ domain }}{% url 'lilcity:password_reset_confirm' uidb64=uid token=token %} \ No newline at end of file +{{ domain }}{% url 'lilcity:password_reset_confirm' uidb64=uid token=token %} diff --git a/apps/auth/views.py b/apps/auth/views.py index 3d4ba19d..8eb52b18 100644 --- a/apps/auth/views.py +++ b/apps/auth/views.py @@ -48,8 +48,9 @@ class LearnerRegistrationView(FormView): # fixme: change email text # fixme: async send email + refferer = self.request.META.get('HTTP_REFERER') token = verification_email_token.make_token(user) - url = self.request.scheme + '://' + self.request.get_host() + str(reverse_lazy('lilcity:verification-email', args=[token])) + url = refferer + str(reverse_lazy('lilcity:verification-email', args=[token])) send_email('Verification Email', email, "notification/email/verification_email.html", url=url) return JsonResponse({"success": True}, status=201) @@ -106,7 +107,9 @@ class PasswordResetView(views.PasswordContextMixin, BaseFormView): token_generator = views.default_token_generator def form_valid(self, form): + refferer = self.request.META.get('HTTP_REFERER') opts = { + 'domain_override': refferer, 'use_https': self.request.is_secure(), 'token_generator': self.token_generator, 'from_email': self.from_email, diff --git a/apps/config/__init__.py b/apps/config/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/apps/config/admin.py b/apps/config/admin.py new file mode 100644 index 00000000..7b75773f --- /dev/null +++ b/apps/config/admin.py @@ -0,0 +1,5 @@ +from django.contrib import admin + +from .models import Config + +admin.site.register(Config) diff --git a/apps/config/apps.py b/apps/config/apps.py new file mode 100644 index 00000000..8d9481b1 --- /dev/null +++ b/apps/config/apps.py @@ -0,0 +1,5 @@ +from django.apps import AppConfig + + +class ConfigConfig(AppConfig): + name = 'config' diff --git a/apps/config/migrations/0001_initial.py b/apps/config/migrations/0001_initial.py new file mode 100644 index 00000000..0aae28c7 --- /dev/null +++ b/apps/config/migrations/0001_initial.py @@ -0,0 +1,27 @@ +# Generated by Django 2.0.3 on 2018-03-26 10:25 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ] + + operations = [ + migrations.CreateModel( + name='Config', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('INSTAGRAM_CLIENT_ACCESS_TOKEN', models.CharField(default='7145314808.f6fa114.6b737a5355534e0eb5cf7c40cb4998f6', max_length=51)), + ('INSTAGRAM_CLIENT_SECRET', models.CharField(default='2334a921425140ccb180d145dcd35b25', max_length=32)), + ('INSTAGRAM_PROFILE_URL', models.CharField(default='#', max_length=126)), + ('SERVICE_COMMISSION', models.IntegerField(default=10)), + ('SERVICE_DISCOUNT_MIN_AMOUNT', models.IntegerField(default=3500)), + ('SERVICE_DISCOUNT', models.ImageField(default=1000, upload_to='')), + ('SCHOOL_LOGO_IMAGE', models.ImageField(null=True, upload_to='')), + ], + ), + ] diff --git a/apps/config/migrations/0002_auto_20180326_1026.py b/apps/config/migrations/0002_auto_20180326_1026.py new file mode 100644 index 00000000..b91b4812 --- /dev/null +++ b/apps/config/migrations/0002_auto_20180326_1026.py @@ -0,0 +1,18 @@ +# Generated by Django 2.0.3 on 2018-03-26 10:26 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('config', '0001_initial'), + ] + + operations = [ + migrations.AlterField( + model_name='config', + name='SCHOOL_LOGO_IMAGE', + field=models.FileField(null=True, upload_to=''), + ), + ] diff --git a/apps/config/migrations/0003_auto_20180326_1027.py b/apps/config/migrations/0003_auto_20180326_1027.py new file mode 100644 index 00000000..19d5c208 --- /dev/null +++ b/apps/config/migrations/0003_auto_20180326_1027.py @@ -0,0 +1,23 @@ +# Generated by Django 2.0.3 on 2018-03-26 10:27 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('config', '0002_auto_20180326_1026'), + ] + + operations = [ + migrations.AlterField( + model_name='config', + name='SCHOOL_LOGO_IMAGE', + field=models.ImageField(null=True, upload_to=''), + ), + migrations.AlterField( + model_name='config', + name='SERVICE_DISCOUNT', + field=models.IntegerField(default=1000), + ), + ] diff --git a/apps/config/migrations/0004_config_main_page_top_image.py b/apps/config/migrations/0004_config_main_page_top_image.py new file mode 100644 index 00000000..5810b8c6 --- /dev/null +++ b/apps/config/migrations/0004_config_main_page_top_image.py @@ -0,0 +1,18 @@ +# Generated by Django 2.0.3 on 2018-03-26 11:20 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('config', '0003_auto_20180326_1027'), + ] + + operations = [ + migrations.AddField( + model_name='config', + name='MAIN_PAGE_TOP_IMAGE', + field=models.ImageField(null=True, upload_to=''), + ), + ] diff --git a/apps/config/migrations/0005_auto_20180326_1314.py b/apps/config/migrations/0005_auto_20180326_1314.py new file mode 100644 index 00000000..af993523 --- /dev/null +++ b/apps/config/migrations/0005_auto_20180326_1314.py @@ -0,0 +1,23 @@ +# Generated by Django 2.0.3 on 2018-03-26 13:14 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('config', '0004_config_main_page_top_image'), + ] + + operations = [ + migrations.AlterField( + model_name='config', + name='MAIN_PAGE_TOP_IMAGE', + field=models.ImageField(blank=True, null=True, upload_to=''), + ), + migrations.AlterField( + model_name='config', + name='SCHOOL_LOGO_IMAGE', + field=models.ImageField(blank=True, null=True, upload_to=''), + ), + ] diff --git a/apps/config/migrations/__init__.py b/apps/config/migrations/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/apps/config/models.py b/apps/config/models.py new file mode 100644 index 00000000..240fe7d5 --- /dev/null +++ b/apps/config/models.py @@ -0,0 +1,39 @@ +from django.db import models + + +class Config(models.Model): + INSTAGRAM_CLIENT_ACCESS_TOKEN = models.CharField( + max_length=51, default='7145314808.f6fa114.6b737a5355534e0eb5cf7c40cb4998f6' + ) + INSTAGRAM_CLIENT_SECRET = models.CharField(max_length=32, default='2334a921425140ccb180d145dcd35b25') + INSTAGRAM_PROFILE_URL = models.CharField(max_length=126, default='#') + SERVICE_COMMISSION = models.IntegerField(default=10) + SERVICE_DISCOUNT_MIN_AMOUNT = models.IntegerField(default=3500) + SERVICE_DISCOUNT = models.IntegerField(default=1000) + SCHOOL_LOGO_IMAGE = models.ImageField(null=True, blank=True) + MAIN_PAGE_TOP_IMAGE = models.ImageField(null=True, blank=True) + + def save(self, *args, **kwargs): + self.pk = 1 + super().save(*args, **kwargs) + + def delete(self, *args, **kwargs): + pass + + @classmethod + def load(cls): + try: + obj, created = cls.objects.get_or_create(pk=1) + except: + # This magic for migrate + obj = { + 'INSTAGRAM_CLIENT_ACCESS_TOKEN': '', + 'INSTAGRAM_CLIENT_SECRET': '', + 'INSTAGRAM_PROFILE_URL': '', + 'SERVICE_COMMISSION': '', + 'SERVICE_DISCOUNT_MIN_AMOUNT': '', + 'SERVICE_DISCOUNT': '', + 'SCHOOL_LOGO_IMAGE': '', + 'MAIN_PAGE_TOP_IMAGE': '', + } + return obj diff --git a/apps/config/tests.py b/apps/config/tests.py new file mode 100644 index 00000000..7ce503c2 --- /dev/null +++ b/apps/config/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/apps/config/views.py b/apps/config/views.py new file mode 100644 index 00000000..91ea44a2 --- /dev/null +++ b/apps/config/views.py @@ -0,0 +1,3 @@ +from django.shortcuts import render + +# Create your views here. diff --git a/apps/content/admin.py b/apps/content/admin.py index 555ba173..39d1fb93 100644 --- a/apps/content/admin.py +++ b/apps/content/admin.py @@ -63,7 +63,6 @@ class ContentAdmin(PolymorphicParentModelAdmin): Text, ImageText, Video, - # GalleryAdmin, ) diff --git a/apps/content/migrations/0015_content_uuid.py b/apps/content/migrations/0015_content_uuid.py new file mode 100644 index 00000000..54a1997c --- /dev/null +++ b/apps/content/migrations/0015_content_uuid.py @@ -0,0 +1,18 @@ +# Generated by Django 2.0.3 on 2018-03-16 11:05 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('content', '0014_auto_20180215_1503'), + ] + + operations = [ + migrations.AddField( + model_name='content', + name='uuid', + field=models.UUIDField(blank=True, null=True), + ), + ] diff --git a/apps/content/models.py b/apps/content/models.py index 345221f4..5c0b9b66 100644 --- a/apps/content/models.py +++ b/apps/content/models.py @@ -17,6 +17,7 @@ class ImageObject(models.Model): class Content(PolymorphicModel): + uuid = models.UUIDField(null=True, blank=True) course = models.ForeignKey( 'course.Course', on_delete=models.CASCADE, null=True, blank=True, diff --git a/apps/content/tasks.py b/apps/content/tasks.py index 9f58dcaa..a3210c2b 100644 --- a/apps/content/tasks.py +++ b/apps/content/tasks.py @@ -3,22 +3,24 @@ import json import requests import shutil -from constance import config from instagram.client import InstagramAPI from project.celery import app from time import sleep from django.conf import settings +from apps.config.models import Config + @app.task def retrieve_photos(): + config = Config.load() api = InstagramAPI( access_token=config.INSTAGRAM_CLIENT_ACCESS_TOKEN, client_secret=config.INSTAGRAM_CLIENT_SECRET, ) recent_media, next_ = api.user_recent_media(user_id='self', count=20) - path = os.path.join(settings.BASE_DIR, config.INSTAGRAM_RESULTS_PATH) + path = os.path.join(settings.BASE_DIR, settings.INSTAGRAM_RESULTS_PATH) for idx, media in enumerate(recent_media): try: fname = os.path.join(path, f'{idx}.jpg') diff --git a/apps/course/migrations/0035_comment_deactivated_at.py b/apps/course/migrations/0035_comment_deactivated_at.py new file mode 100644 index 00000000..cd5ba08e --- /dev/null +++ b/apps/course/migrations/0035_comment_deactivated_at.py @@ -0,0 +1,18 @@ +# Generated by Django 2.0.2 on 2018-03-12 10:05 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('course', '0034_auto_20180215_1503'), + ] + + operations = [ + migrations.AddField( + model_name='comment', + name='deactivated_at', + field=models.DateTimeField(blank=True, default=None, null=True), + ), + ] diff --git a/apps/course/models.py b/apps/course/models.py index ad59b1b1..98a64423 100644 --- a/apps/course/models.py +++ b/apps/course/models.py @@ -196,7 +196,7 @@ class Material(models.Model): ordering = ('title',) -class Comment(PolymorphicMPTTModel): +class Comment(PolymorphicMPTTModel, DeactivatedMixin): content = models.TextField('Текст комментария', default='') author = models.ForeignKey(User, on_delete=models.CASCADE) parent = PolymorphicTreeForeignKey( diff --git a/apps/course/templates/course/_items.html b/apps/course/templates/course/_items.html index 260a482e..f7b7d666 100644 --- a/apps/course/templates/course/_items.html +++ b/apps/course/templates/course/_items.html @@ -1,3 +1,4 @@ +{% load thumbnail %} {% load static %} {% load data_liked from data_liked %} @@ -7,11 +8,11 @@ {% if course.is_deferred_start %}data-future-course data-future-course-time={{ course.deferred_start_at.timestamp }}{% endif %} > - {% if course.cover %} - - {% else %} - - {% endif %} + {% thumbnail course.cover.image "300x170" crop="center" as im %} + + {% empty %} + + {% endthumbnail %}
Подробнее
{% if course.is_featured %}
@@ -32,6 +33,16 @@
ЧЕРНОВИК
+ {% elif course.status == 3 %} +
+
В АРХИВЕ
+
+
+ {% elif course.status == 4 %} +
+
ОТКЛОНЕН
+
+
{% endif %}
@@ -86,4 +97,4 @@
- \ No newline at end of file + diff --git a/apps/course/templates/course/blocks/comment.html b/apps/course/templates/course/blocks/comment.html index 1f338358..096d0175 100644 --- a/apps/course/templates/course/blocks/comment.html +++ b/apps/course/templates/course/blocks/comment.html @@ -1,6 +1,7 @@ {% load static %} - -
+{% if not node.deactivated_at %} + +
{% if node.author.photo %}
@@ -24,4 +25,5 @@ {% endif %}
-
\ No newline at end of file + +{% endif %} diff --git a/apps/course/templates/course/blocks/comments.html b/apps/course/templates/course/blocks/comments.html index c762ebc5..b2b74983 100644 --- a/apps/course/templates/course/blocks/comments.html +++ b/apps/course/templates/course/blocks/comments.html @@ -1,5 +1,10 @@ {% load mptt_tags %} {% recursetree object.comments.all %} +{% if not node.deactivated_at %} {% include './comment.html' %} -{{ children }} {% endrecursetree %} \ No newline at end of file +{% if not node.is_leaf_node %} +{{ children }} +{% endif %} +{% endif %} +{% endrecursetree %} diff --git a/apps/course/templates/course/content/gallery.html b/apps/course/templates/course/content/gallery.html index 5cae9613..6d40a0b2 100644 --- a/apps/course/templates/course/content/gallery.html +++ b/apps/course/templates/course/content/gallery.html @@ -1,35 +1,32 @@ +{% load thumbnail %} {% if results %} -
Галерея итогов обучения
-
- {% for image in course.gallery.gallery_images.all %} -
- +
Галерея итогов обучения
+ - {% endfor %} -
{% else %} -
-
-
{{ content.title }}
-
- {% for image in content.gallery_images.all %} +
+
+
{{ content.title }}
+
+ {% for image in content.gallery_images.all %} - {% endfor %} + {% endfor %}
-
-{% endif %} - - - \ No newline at end of file +{% endif %} diff --git a/apps/course/templates/course/content/image.html b/apps/course/templates/course/content/image.html index 9a5e5e7a..4ab24601 100644 --- a/apps/course/templates/course/content/image.html +++ b/apps/course/templates/course/content/image.html @@ -7,4 +7,4 @@
-
\ No newline at end of file +
diff --git a/apps/course/templates/course/content/imagetext.html b/apps/course/templates/course/content/imagetext.html index 9e59062c..62ec511b 100644 --- a/apps/course/templates/course/content/imagetext.html +++ b/apps/course/templates/course/content/imagetext.html @@ -10,4 +10,4 @@ - \ No newline at end of file + diff --git a/apps/course/templates/course/course.html b/apps/course/templates/course/course.html index 4606e9b0..f3069394 100644 --- a/apps/course/templates/course/course.html +++ b/apps/course/templates/course/course.html @@ -14,7 +14,7 @@ {% block ogdescription %}{{ course.short_description }}{% endblock ogdescription %} {% block content %} -
+
@@ -30,6 +30,7 @@ class="go__btn btn{% if pending %} btn_gray{% endif %} btn_md" {% if user.is_authenticated %} {% if not pending %} + data-course-buy href="{% url 'course-checkout' course.id %}" {% endif %} {% else %} @@ -173,7 +174,7 @@ {% if course.is_deferred_start %}
Курс начнется:
-
{{ course.deferred_start_at_humanize }}
+
{{ course.deferred_start_at_humanize }}
{% else %} {% comment %} @@ -392,6 +393,7 @@ class="go__btn btn{% if pending %} btn_gray{% endif %} btn_md" {% if user.is_authenticated %} {% if not pending %} + data-course-buy href="{% url 'course-checkout' course.id %}" {% endif %} {% else %} @@ -407,11 +409,18 @@
Задавайте вопросы:
- {% if user.is_authenticated %} + {% if request.user.is_authenticated %}
- +
В ответ на diff --git a/apps/course/templates/course/course_edit.html b/apps/course/templates/course/course_edit.html index 5858e4c3..521e8d0b 100644 --- a/apps/course/templates/course/course_edit.html +++ b/apps/course/templates/course/course_edit.html @@ -1,13 +1,19 @@ {% extends "templates/lilcity/edit_index.html" %} {% load static %} -{% block title %}{% if course %}Редактирование курса {{ course.title }}{% else %}Создание курса{% endif %}{% endblock title%} +{% block title %} + {% if course %} + Редактирование {% if not live %}курса{% else %}стрима{% endif %} {{ course.title }} + {% else %} + Создание {% if not live %}курса{% else %}стрима{% endif %} + {% endif %} +{% endblock title%} {% block content %} - {% endblock content %} {% block foot %} - - -{% endblock foot %} \ No newline at end of file + + +{% endblock foot %} diff --git a/apps/course/templates/course/lesson.html b/apps/course/templates/course/lesson.html index f2427d05..9150710d 100644 --- a/apps/course/templates/course/lesson.html +++ b/apps/course/templates/course/lesson.html @@ -33,7 +33,7 @@ {% else %} - {% endif %} + {% endif %} @@ -95,10 +95,17 @@
Задавайте вопросы:
- {% if user.is_authenticated %} + {% if request.user.is_authenticated %}
- +
diff --git a/apps/course/views.py b/apps/course/views.py index 68fd1b1a..a7fb0abe 100644 --- a/apps/course/views.py +++ b/apps/course/views.py @@ -7,6 +7,7 @@ from django.http import JsonResponse, Http404 from django.shortcuts import get_object_or_404 from django.template import loader, Context, Template from django.views.generic import View, CreateView, DetailView, ListView, TemplateView +from django.utils.cache import add_never_cache_headers from django.utils.decorators import method_decorator from django.views.decorators.csrf import csrf_exempt from django.views.decorators.http import require_http_methods @@ -170,11 +171,37 @@ class CourseEditView(TemplateView): def get_context_data(self): context = super().get_context_data() + context['live'] = 'false' if self.object: context['course'] = self.object return context +@method_decorator(login_required, name='dispatch') +class CourseLiveEditView(TemplateView): + template_name = 'course/course_edit.html' + + def get(self, request, pk=None): + drafts = Course.objects.filter( + author=request.user, status=Course.DRAFT + ) + if pk: + self.object = get_object_or_404(Course, pk=pk) + elif drafts.exists(): + self.object = drafts.last() + else: + self.object = Course.objects.create() + if request.user != self.object.author and request.user.role not in [User.ADMIN_ROLE, User.AUTHOR_ROLE]: + raise Http404 + return super().get(request) + + def get_context_data(self): + context = super().get_context_data() + context['live'] = 'true' + if self.object: + context['course'] = self.object + return context + # @method_decorator(login_required, name='dispatch') class CourseView(DetailView): model = Course @@ -185,7 +212,7 @@ class CourseView(DetailView): def get(self, request, *args, **kwargs): response = super().get(request, *args, **kwargs) context = self.get_context_data() - if not request.user.is_authenticated or (not request.user.is_authenticated and self.object.status != Course.PUBLISHED) or\ + if (not request.user.is_authenticated and self.object.status != Course.PUBLISHED) or\ (request.user.is_authenticated and request.user.role not in [User.AUTHOR_ROLE, User.ADMIN_ROLE] and self.object.author != request.user and self.only_lessons and not context['paid']): raise Http404 return response @@ -245,12 +272,14 @@ class CoursesView(ListView): else: prev_url = None next_url = None - return JsonResponse({ + response = JsonResponse({ 'success': True, 'content': html, 'prev_url': prev_url, 'next_url': next_url, }) + add_never_cache_headers(response) + return response else: return super().get(request, args, kwargs) diff --git a/apps/notification/templates/notification/email/_base.html b/apps/notification/templates/notification/email/_base.html index b4ce1c89..7ae29045 100644 --- a/apps/notification/templates/notification/email/_base.html +++ b/apps/notification/templates/notification/email/_base.html @@ -45,7 +45,7 @@ - +
2017 © Lil City, UAB. {% now "Y" %} © Lil City, UAB. diff --git a/apps/notification/templates/notification/email/accept_author.html b/apps/notification/templates/notification/email/accept_author.html new file mode 100644 index 00000000..44221ba5 --- /dev/null +++ b/apps/notification/templates/notification/email/accept_author.html @@ -0,0 +1,13 @@ +{% extends "notification/email/_base.html" %} + +{% block content %} +

Поздравляем! Вам одобрено назначение преподавателем!

+
+

Теперь вы можете публиковать курсы.

+ {% if password and email %} +

Параметры входа:

+

email: {{ email }}

+

пароль: {{ password }}

+ {% endif %} +
+{% endblock content %} diff --git a/apps/notification/templates/notification/email/decline_author.html b/apps/notification/templates/notification/email/decline_author.html new file mode 100644 index 00000000..d67dfe3e --- /dev/null +++ b/apps/notification/templates/notification/email/decline_author.html @@ -0,0 +1,8 @@ +{% extends "notification/email/_base.html" %} + +{% block content %} +

К сожалению вам отказано в назначении преподавателем!

+
+

{{ cause }}

+
+{% endblock content %} diff --git a/apps/notification/utils.py b/apps/notification/utils.py index bc206a13..c04b3c0e 100644 --- a/apps/notification/utils.py +++ b/apps/notification/utils.py @@ -3,8 +3,10 @@ from twilio.rest import Client from django.core.mail import EmailMessage from django.conf import settings from django.template.loader import get_template +from project.celery import app +@app.task def send_email(subject, to_email, template_name, **kwargs): html = get_template(template_name).render(kwargs) email = EmailMessage(subject, html, to=[to_email]) diff --git a/apps/payment/models.py b/apps/payment/models.py index 5ec8e1ac..8f1f36d8 100644 --- a/apps/payment/models.py +++ b/apps/payment/models.py @@ -1,4 +1,3 @@ -from constance import config from paymentwall import Pingback from polymorphic.models import PolymorphicModel @@ -9,9 +8,12 @@ from django.core.validators import RegexValidator from django.utils.timezone import now from apps.course.models import Course +from apps.config.models import Config from apps.school.models import SchoolSchedule from apps.notification.utils import send_email + +config = Config.load() User = get_user_model() CREDIT_CARD_RE = r'^(?:4[0-9]{12}(?:[0-9]{3})?|5[1-5][0-9]{14}|6(?:011|5[0-9][0-9])[0-9]{12}|3[47][0-9]{13}|3(?:0[0-5]|[68][0-9])[0-9]{11}|(?:2131|1800|35\\d{3})\d{11})$' @@ -157,7 +159,7 @@ class SchoolPayment(Payment): models.Sum('month_price'), ) month_price_sum = aggregate.get('month_price__sum', 0) - if len(self.weekdays) > 7: + if month_price_sum > config.SERVICE_DISCOUNT_MIN_AMOUNT: discount = config.SERVICE_DISCOUNT else: discount = 0 diff --git a/apps/payment/tasks.py b/apps/payment/tasks.py new file mode 100644 index 00000000..e5a3f296 --- /dev/null +++ b/apps/payment/tasks.py @@ -0,0 +1,18 @@ +from mixpanel import Mixpanel + +from django.conf import settings + +from project.celery import app + + +@app.task +def transaction_to_mixpanel(user_id, amount, time, product_type): + mix = Mixpanel(settings.MIX_TOKEN) + mix.people_track_charge( + user_id, + amount, + { + '$time': time, + 'product_type': product_type, + } + ) diff --git a/apps/payment/templates/payment/course_payment_success.html b/apps/payment/templates/payment/course_payment_success.html new file mode 100644 index 00000000..3edd1879 --- /dev/null +++ b/apps/payment/templates/payment/course_payment_success.html @@ -0,0 +1,12 @@ +{% extends "templates/lilcity/index.html" %} {% load static %} {% block content %} +
+{% endblock content %} diff --git a/apps/payment/views.py b/apps/payment/views.py index 52ccd274..7f68a963 100644 --- a/apps/payment/views.py +++ b/apps/payment/views.py @@ -6,7 +6,7 @@ from datetime import timedelta from django.contrib import messages from django.contrib.auth.decorators import login_required from django.http import HttpResponse -from django.shortcuts import redirect +from django.shortcuts import redirect, get_object_or_404 from django.views.generic import View, TemplateView from django.views.decorators.csrf import csrf_exempt from django.urls import reverse_lazy @@ -17,12 +17,27 @@ from paymentwall import Pingback, Product, Widget from apps.course.models import Course from apps.school.models import SchoolSchedule +from apps.payment.tasks import transaction_to_mixpanel from .models import AuthorBalance, CoursePayment, SchoolPayment logger = logging.getLogger('django') +@method_decorator(login_required, name='dispatch') +class CourseBuySuccessView(TemplateView): + template_name = 'payment/course_payment_success.html' + + def get(self, request, pk=None, *args, **kwargs): + course = get_object_or_404(Course, pk=pk) + return self.render_to_response(context={'course': course}) + + +@method_decorator(login_required, name='dispatch') +class SchoolBuySuccessView(TemplateView): + template_name = 'payment/payment_success.html' + + @method_decorator(login_required, name='dispatch') class CourseBuyView(TemplateView): template_name = 'payment/paymentwall_widget.html' @@ -31,7 +46,7 @@ class CourseBuyView(TemplateView): host = request.scheme + '://' + request.get_host() course = Course.objects.get(id=pk) if request.user == course.author: - messages.error('Вы не можете приобрести свой курс.') + messages.error(request, 'Вы не можете приобрести свой курс.') return redirect(reverse_lazy('course', args=[course.id])) course_payment = CoursePayment.objects.create( user=request.user, @@ -52,7 +67,7 @@ class CourseBuyView(TemplateView): 'evaluation': 1, 'demo': 1, 'test_mode': 1, - 'success_url': host + str(reverse_lazy('payment-success')), + 'success_url': host + str(reverse_lazy('course_payment_success', args=[course.id])), 'failure_url': host + str(reverse_lazy('payment-error')), } ) @@ -132,27 +147,35 @@ class PaymentwallCallbackView(View): logger.info( json.dumps(payment_raw_data), ) + payment.status = pingback.get_type() payment.data = payment_raw_data - if pingback.is_deliverable() and product_type_name == 'school': - school_payment = SchoolPayment.objects.filter( - user=payment.user, - date_start__lte=now(), - date_end__gt=now(), - status__in=[ - Pingback.PINGBACK_TYPE_REGULAR, - Pingback.PINGBACK_TYPE_GOODWILL, - Pingback.PINGBACK_TYPE_RISK_REVIEWED_ACCEPTED, - ], - ).last() - if school_payment: - date_start = school_payment.date_end + timedelta(days=1) - date_end = date_start + timedelta(days=30) - else: - date_start = now() - date_end = now() + timedelta(days=30) - payment.date_start = date_start - payment.date_end = date_end + if pingback.is_deliverable(): + transaction_to_mixpanel.delay( + payment.user.id, + payment.amount, + now().strftime('%Y-%m-%dT%H:%M:%S'), + product_type_name, + ) + if product_type_name == 'school': + school_payment = SchoolPayment.objects.filter( + user=payment.user, + date_start__lte=now(), + date_end__gt=now(), + status__in=[ + Pingback.PINGBACK_TYPE_REGULAR, + Pingback.PINGBACK_TYPE_GOODWILL, + Pingback.PINGBACK_TYPE_RISK_REVIEWED_ACCEPTED, + ], + ).last() + if school_payment: + date_start = school_payment.date_end + timedelta(days=1) + date_end = date_start + timedelta(days=30) + else: + date_start = now() + date_end = now() + timedelta(days=30) + payment.date_start = date_start + payment.date_end = date_end payment.save() author_balance = getattr(payment, 'author_balance', None) diff --git a/apps/user/admin.py b/apps/user/admin.py index 76e27edc..df3e01d9 100644 --- a/apps/user/admin.py +++ b/apps/user/admin.py @@ -3,6 +3,8 @@ from django.contrib.auth import get_user_model from django.contrib.auth.admin import UserAdmin as BaseUserAdmin from django.utils.translation import gettext_lazy as _ +from .models import AuthorRequest, EmailSubscription, SubscriptionCategory + User = get_user_model() @@ -13,7 +15,35 @@ class UserAdmin(BaseUserAdmin): (_('Personal info'), {'fields': ('first_name', 'last_name', 'email', 'gender', 'about', 'photo')}), ('Facebook Auth data', {'fields': ('fb_id', 'fb_data', 'is_email_proved')}), (_('Permissions'), {'fields': ('role', 'is_active', 'is_staff', 'is_superuser', - 'groups', 'user_permissions')}), + 'groups', 'user_permissions', 'show_in_mainpage')}), (_('Important dates'), {'fields': ('last_login', 'date_joined')}), ('Social urls', {'fields': ('instagram', 'facebook', 'twitter', 'pinterest', 'youtube', 'vkontakte', )}), ) + + +@admin.register(AuthorRequest) +class AuthorRequestAdmin(admin.ModelAdmin): + list_display = ( + 'email', + 'first_name', + 'last_name', + 'status', + 'accepted_send_at', + 'declined_send_at', + 'created_at', + 'update_at', + ) + + +@admin.register(SubscriptionCategory) +class SubscriptionCategoryAdmin(admin.ModelAdmin): + list_display = ('title',) + + +@admin.register(EmailSubscription) +class EmailSubscriptionAdmin(admin.ModelAdmin): + list_display = ( + 'id', + 'user', + 'email', + ) diff --git a/apps/user/fixtures/subscription_categories.json b/apps/user/fixtures/subscription_categories.json new file mode 100644 index 00000000..07b3fd10 --- /dev/null +++ b/apps/user/fixtures/subscription_categories.json @@ -0,0 +1,58 @@ +[ + { + "model": "user.subscriptioncategory", + "pk": 1, + "fields": { + "title": "Новости школы", + "auto_add": false + } + }, + { + "model": "user.subscriptioncategory", + "pk": 2, + "fields": { + "title": "Новые курсы", + "auto_add": true + } + }, + { + "model": "user.subscriptioncategory", + "pk": 3, + "fields": { + "title": "Бонусы от партнёров", + "auto_add": false + } + }, + { + "model": "user.subscriptioncategory", + "pk": 4, + "fields": { + "title": "Акции", + "auto_add": true + } + }, + { + "model": "user.subscriptioncategory", + "pk": 5, + "fields": { + "title": "Партнёрские акции", + "auto_add": false + } + }, + { + "model": "user.subscriptioncategory", + "pk": 6, + "fields": { + "title": "Новости компании", + "auto_add": true + } + }, + { + "model": "user.subscriptioncategory", + "pk": 7, + "fields": { + "title": "Комментарии в которых участвуете", + "auto_add": false + } + } +] diff --git a/apps/user/forms.py b/apps/user/forms.py index 2f75ef68..2445d522 100644 --- a/apps/user/forms.py +++ b/apps/user/forms.py @@ -1,5 +1,6 @@ from django import forms from django.contrib.auth import get_user_model +from phonenumber_field.formfields import PhoneNumberField from .fields import CreditCardField @@ -10,6 +11,7 @@ class UserEditForm(forms.ModelForm): # first_name = forms.CharField() # last_name = forms.CharField() # email = forms.CharField() + phone = PhoneNumberField() # city = forms.CharField() # country = forms.CharField() birthday = forms.DateField(input_formats=['%d.%m.%Y'], required=False) @@ -33,6 +35,7 @@ class UserEditForm(forms.ModelForm): 'first_name', 'last_name', 'email', + 'phone', 'city', 'country', 'birthday', @@ -54,3 +57,11 @@ class UserEditForm(forms.ModelForm): class WithdrawalForm(forms.Form): amount = forms.DecimalField(required=True, min_value=2000) card = CreditCardField(required=True) + + +class AuthorRequesForm(forms.Form): + first_name = forms.CharField() + last_name = forms.CharField() + email = forms.CharField() + about = forms.CharField() + facebook = forms.URLField(required=False) diff --git a/apps/user/migrations/0009_authorrequest.py b/apps/user/migrations/0009_authorrequest.py new file mode 100644 index 00000000..9e10d01c --- /dev/null +++ b/apps/user/migrations/0009_authorrequest.py @@ -0,0 +1,27 @@ +# Generated by Django 2.0.2 on 2018-03-12 14:01 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('user', '0008_auto_20180212_0750'), + ] + + operations = [ + migrations.CreateModel( + name='AuthorRequest', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('first_name', models.CharField(max_length=30, verbose_name='first name')), + ('last_name', models.CharField(max_length=150, verbose_name='last name')), + ('email', models.EmailField(max_length=254, verbose_name='email address')), + ('about', models.CharField(blank=True, max_length=1000, null=True, verbose_name='О себе')), + ('facebook', models.URLField(blank=True, default='', null=True)), + ('status', models.PositiveSmallIntegerField(choices=[(0, 'pending'), (1, 'accepted'), (2, 'declined')], default=0)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('update_at', models.DateTimeField(auto_now=True)), + ], + ), + ] diff --git a/apps/user/migrations/0010_auto_20180312_1610.py b/apps/user/migrations/0010_auto_20180312_1610.py new file mode 100644 index 00000000..b691deea --- /dev/null +++ b/apps/user/migrations/0010_auto_20180312_1610.py @@ -0,0 +1,18 @@ +# Generated by Django 2.0.2 on 2018-03-12 16:10 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('user', '0009_authorrequest'), + ] + + operations = [ + migrations.AlterField( + model_name='authorrequest', + name='email', + field=models.EmailField(max_length=254, unique=True, verbose_name='email address'), + ), + ] diff --git a/apps/user/migrations/0011_auto_20180313_0744.py b/apps/user/migrations/0011_auto_20180313_0744.py new file mode 100644 index 00000000..3fe91ef7 --- /dev/null +++ b/apps/user/migrations/0011_auto_20180313_0744.py @@ -0,0 +1,17 @@ +# Generated by Django 2.0.3 on 2018-03-13 07:44 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('user', '0010_auto_20180312_1610'), + ] + + operations = [ + migrations.AlterModelOptions( + name='authorrequest', + options={'ordering': ('-created_at',), 'verbose_name': 'Заявка не преподавателя', 'verbose_name_plural': 'Заявки не преподавателя'}, + ), + ] diff --git a/apps/user/migrations/0012_authorrequest_cause.py b/apps/user/migrations/0012_authorrequest_cause.py new file mode 100644 index 00000000..fef855a2 --- /dev/null +++ b/apps/user/migrations/0012_authorrequest_cause.py @@ -0,0 +1,18 @@ +# Generated by Django 2.0.3 on 2018-03-13 07:49 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('user', '0011_auto_20180313_0744'), + ] + + operations = [ + migrations.AddField( + model_name='authorrequest', + name='cause', + field=models.TextField(blank=True, null=True, verbose_name='Причина отказа'), + ), + ] diff --git a/apps/user/migrations/0013_authorrequest_declined_send_at.py b/apps/user/migrations/0013_authorrequest_declined_send_at.py new file mode 100644 index 00000000..50a6a738 --- /dev/null +++ b/apps/user/migrations/0013_authorrequest_declined_send_at.py @@ -0,0 +1,18 @@ +# Generated by Django 2.0.3 on 2018-03-13 10:10 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('user', '0012_authorrequest_cause'), + ] + + operations = [ + migrations.AddField( + model_name='authorrequest', + name='declined_send_at', + field=models.DateTimeField(blank=True, null=True), + ), + ] diff --git a/apps/user/migrations/0014_authorrequest_accepted_send_at.py b/apps/user/migrations/0014_authorrequest_accepted_send_at.py new file mode 100644 index 00000000..284c5909 --- /dev/null +++ b/apps/user/migrations/0014_authorrequest_accepted_send_at.py @@ -0,0 +1,18 @@ +# Generated by Django 2.0.3 on 2018-03-13 10:55 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('user', '0013_authorrequest_declined_send_at'), + ] + + operations = [ + migrations.AddField( + model_name='authorrequest', + name='accepted_send_at', + field=models.DateTimeField(blank=True, null=True), + ), + ] diff --git a/apps/user/migrations/0015_auto_20180315_0547.py b/apps/user/migrations/0015_auto_20180315_0547.py new file mode 100644 index 00000000..dddc43d5 --- /dev/null +++ b/apps/user/migrations/0015_auto_20180315_0547.py @@ -0,0 +1,45 @@ +# Generated by Django 2.0.3 on 2018-03-15 05:47 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('user', '0014_authorrequest_accepted_send_at'), + ] + + operations = [ + migrations.CreateModel( + name='EmailSubscription', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('email', models.EmailField(max_length=254, unique=True, verbose_name='email address')), + ('mailchimp_status', models.PositiveSmallIntegerField(choices=[(0, 'error'), (1, 'sent')], default=0)), + ], + ), + migrations.CreateModel( + name='SubscriptionCategory', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('title', models.CharField(max_length=100)), + ], + options={ + 'verbose_name': 'Категория подписки', + 'verbose_name_plural': 'Категории подписки', + 'ordering': ('title',), + }, + ), + migrations.AddField( + model_name='emailsubscription', + name='categories', + field=models.ManyToManyField(to='user.SubscriptionCategory'), + ), + migrations.AddField( + model_name='emailsubscription', + name='user', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL), + ), + ] diff --git a/apps/user/migrations/0016_auto_20180315_0603.py b/apps/user/migrations/0016_auto_20180315_0603.py new file mode 100644 index 00000000..3c5d3f47 --- /dev/null +++ b/apps/user/migrations/0016_auto_20180315_0603.py @@ -0,0 +1,20 @@ +# Generated by Django 2.0.3 on 2018-03-15 06:03 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('user', '0015_auto_20180315_0547'), + ] + + operations = [ + migrations.AlterField( + model_name='emailsubscription', + name='user', + field=models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='email_subscription', to=settings.AUTH_USER_MODEL), + ), + ] diff --git a/apps/user/migrations/0017_subscriptioncategory_auto_add.py b/apps/user/migrations/0017_subscriptioncategory_auto_add.py new file mode 100644 index 00000000..9530f62c --- /dev/null +++ b/apps/user/migrations/0017_subscriptioncategory_auto_add.py @@ -0,0 +1,18 @@ +# Generated by Django 2.0.3 on 2018-03-15 06:18 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('user', '0016_auto_20180315_0603'), + ] + + operations = [ + migrations.AddField( + model_name='subscriptioncategory', + name='auto_add', + field=models.BooleanField(default=False), + ), + ] diff --git a/apps/user/migrations/0018_user_phone.py b/apps/user/migrations/0018_user_phone.py new file mode 100644 index 00000000..d377bcf5 --- /dev/null +++ b/apps/user/migrations/0018_user_phone.py @@ -0,0 +1,19 @@ +# Generated by Django 2.0.3 on 2018-03-15 17:19 + +from django.db import migrations +import phonenumber_field.modelfields + + +class Migration(migrations.Migration): + + dependencies = [ + ('user', '0017_subscriptioncategory_auto_add'), + ] + + operations = [ + migrations.AddField( + model_name='user', + name='phone', + field=phonenumber_field.modelfields.PhoneNumberField(blank=True, max_length=128, null=True, unique=True), + ), + ] diff --git a/apps/user/migrations/0019_user_show_in_mainpage.py b/apps/user/migrations/0019_user_show_in_mainpage.py new file mode 100644 index 00000000..e624bba1 --- /dev/null +++ b/apps/user/migrations/0019_user_show_in_mainpage.py @@ -0,0 +1,18 @@ +# Generated by Django 2.0.3 on 2018-03-20 08:40 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('user', '0018_user_phone'), + ] + + operations = [ + migrations.AddField( + model_name='user', + name='show_in_mainpage', + field=models.BooleanField(default=False, verbose_name='Показывать на главной странице'), + ), + ] diff --git a/apps/user/models.py b/apps/user/models.py index 43eac1cd..8d29759f 100644 --- a/apps/user/models.py +++ b/apps/user/models.py @@ -1,14 +1,19 @@ +from json import dumps +from rest_framework.authtoken.models import Token +from phonenumber_field.modelfields import PhoneNumberField + from django.db import models from django.db.models.signals import post_save from django.dispatch import receiver from django.contrib.auth.models import AbstractUser, UserManager from django.contrib.postgres import fields as pgfields +from django.utils.timezone import now from django.utils.translation import gettext_lazy as _ -from rest_framework.authtoken.models import Token - -from json import dumps from api.v1 import serializers +from apps.notification.utils import send_email + +from apps.user.tasks import user_to_mixpanel class User(AbstractUser): @@ -29,6 +34,7 @@ class User(AbstractUser): (FEMALE, 'Женщина'), ) email = models.EmailField(_('email address'), unique=True) + phone = PhoneNumberField(null=True, blank=True, unique=True) role = models.PositiveSmallIntegerField( 'Роль', default=0, choices=ROLE_CHOICES) gender = models.CharField( @@ -49,6 +55,7 @@ class User(AbstractUser): 'Верифицирован по email', default=False ) photo = models.ImageField('Фото', null=True, blank=True, upload_to='users') + show_in_mainpage = models.BooleanField('Показывать на главной странице', default=False) USERNAME_FIELD = 'email' REQUIRED_FIELDS = ['username'] @@ -63,7 +70,7 @@ class User(AbstractUser): @property def balance(self): - aggregate = self.balances.aggregate( + aggregate = self.balances.filter(type=0).aggregate( models.Sum('amount'), models.Sum('commission'), ) @@ -85,3 +92,149 @@ def create_auth_token(sender, instance=None, created=False, **kwargs): instance.role not in [User.AUTHOR_ROLE, User.ADMIN_ROLE] ) and hasattr(instance, 'auth_token'): instance.auth_token.delete() + + +@receiver(post_save, sender=User) +def send_user_info_to_mixpanel(sender, instance=None, created=False, **kwargs): + user_to_mixpanel.delay( + instance.id, + instance.email, + str(instance.phone), + instance.first_name, + instance.last_name, + instance.date_joined, + dict(User.ROLE_CHOICES).get(instance.role), + [subscription.title.lower() for subscription in instance.email_subscription.categories.all()] if hasattr(instance, 'email_subscription') else [], + ) + + +@receiver(post_save, sender=User) +def auto_create_subscription(sender, instance=None, created=False, **kwargs): + try: + es = EmailSubscription.objects.get(email=instance.email) + if not es.user: + es.user = instance + es.save() + except EmailSubscription.DoesNotExist: + instance.email_subscription = EmailSubscription.objects.create( + user=instance, + email=instance.email, + ) + instance.email_subscription.categories.set(SubscriptionCategory.objects.filter(auto_add=True)) + instance.save() + + +class AuthorRequestManager(models.Manager): + def create_by_user(self, user): + obj = self.model( + first_name=user.first_name, + last_name=user.last_name, + email=user.email, + about=user.about, + facebook=user.facebook, + ) + self._for_write = True + obj.save(force_insert=True, using=self.db) + return obj + + +class AuthorRequest(models.Model): + PENDING = 0 + ACCEPTED = 1 + DECLINED = 2 + STATUS_CHOICES = ( + (PENDING, 'pending'), + (ACCEPTED, 'accepted'), + (DECLINED, 'declined'), + ) + + first_name = models.CharField(_('first name'), max_length=30) + last_name = models.CharField(_('last name'), max_length=150) + email = models.EmailField(_('email address'), unique=True) + about = models.CharField('О себе', max_length=1000, null=True, blank=True) + facebook = models.URLField(default='', null=True, blank=True) + status = models.PositiveSmallIntegerField(choices=STATUS_CHOICES, default=PENDING) + cause = models.TextField('Причина отказа', null=True, blank=True) + + accepted_send_at = models.DateTimeField(null=True, blank=True) + declined_send_at = models.DateTimeField(null=True, blank=True) + created_at = models.DateTimeField(auto_now_add=True) + update_at = models.DateTimeField(auto_now=True) + + objects = AuthorRequestManager() + + class Meta: + verbose_name = 'Заявка не преподавателя' + verbose_name_plural = 'Заявки не преподавателя' + ordering = ('-created_at',) + + +@receiver(post_save, sender=AuthorRequest) +def handle_authorrequest_update(sender, instance=None, created=False, update_fields=[], **kwargs): + if not created: + if instance.status == AuthorRequest.DECLINED and not instance.declined_send_at: + send_email.delay( + 'Отказ заявки на преподавателя', + instance.email, + 'notification/email/decline_author.html', + cause=instance.cause, + ) + instance.declined_send_at = now() + instance.save() + elif instance.status == AuthorRequest.ACCEPTED and not instance.accepted_send_at: + email = None + password = None + try: + user = User.objects.get(email=instance.email) + except User.DoesNotExist: + email = instance.email + password = User.objects.make_random_password() + user = User.objects.create( + first_name=instance.first_name, + last_name=instance.last_name, + username=instance.email, + email=instance.email, + about=instance.about, + facebook=instance.facebook, + is_active=True, + is_email_proved=True, + ) + user.set_password(password) + user.role = User.AUTHOR_ROLE + user.save() + send_email.delay( + 'Заявка на преподавателя одобрена', + instance.email, + 'notification/email/accept_author.html', + email=email, + password=password, + ) + instance.accepted_send_at = now() + instance.save() + + +class SubscriptionCategory(models.Model): + title = models.CharField(max_length=100) + auto_add = models.BooleanField(default=False) + + class Meta: + verbose_name = 'Категория подписки' + verbose_name_plural = 'Категории подписки' + ordering = ('title',) + + def __str__(self): + return self.title + + +class EmailSubscription(models.Model): + ERROR = 0 + SENT = 1 + MAILCHIMP_STATUS_CHOICES = ( + (ERROR, 'error'), + (SENT, 'sent'), + ) + + user = models.OneToOneField(User, null=True, blank=True, on_delete=models.CASCADE, related_name='email_subscription') + email = models.EmailField(_('email address'), unique=True) + categories = models.ManyToManyField(SubscriptionCategory) + mailchimp_status = models.PositiveSmallIntegerField(choices=MAILCHIMP_STATUS_CHOICES, default=ERROR) diff --git a/apps/user/tasks.py b/apps/user/tasks.py new file mode 100644 index 00000000..a95988be --- /dev/null +++ b/apps/user/tasks.py @@ -0,0 +1,22 @@ +from mixpanel import Mixpanel + +from django.conf import settings + +from project.celery import app + + +@app.task +def user_to_mixpanel(user_id, email, phone, first_name, last_name, date_joined, role, subscriptions): + mix = Mixpanel(settings.MIX_TOKEN) + mix.people_set( + user_id, + { + '$email': email, + '$phone': phone, + '$first_name': first_name, + '$last_name': last_name, + '$created': date_joined, + 'role': role, + 'subscriptions': subscriptions, + } + ) diff --git a/apps/user/templates/user/become-author-success.html b/apps/user/templates/user/become-author-success.html new file mode 100644 index 00000000..e6e97f7d --- /dev/null +++ b/apps/user/templates/user/become-author-success.html @@ -0,0 +1,12 @@ +{% extends "templates/lilcity/index.html" %} {% load static %} {% block content %} +
+
+
+
Ваша заявка отправлена!
+ +
+
+
+{% endblock content %} diff --git a/apps/user/templates/user/become-author.html b/apps/user/templates/user/become-author.html new file mode 100644 index 00000000..383b81ab --- /dev/null +++ b/apps/user/templates/user/become-author.html @@ -0,0 +1,69 @@ +{% extends "templates/lilcity/index.html" %} {% load static %} {% block content %} {% if messages %} +
+
+ {% for message in messages %} +
{{ message }}
+ {% endfor %} +
+
+{% endif %} +
+
+ {% csrf_token %} +
+
Стать автором
+
+
+
ИМЯ
+
+ +
+ {% if form.first_name.errors %} +
Укажите корректно свои данные
+ {% endif %} +
+
+
ФАМИЛИЯ
+
+ +
+ {% if form.last_name.errors %} +
Укажите корректно свои данные
+ {% endif %} +
+
+
+
Почта
+
+ +
+ {% if form.email.errors %} +
Укажите корректно свои данные
+ {% endif %} +
+
+
О себе
+
+ +
+ {% if form.about.errors %} +
Укажите корректно свои данные
+ {% endif %} +
+
+
FACEBOOK
+
+ +
+ {% if form.facebook.errors %} +
Укажите корректно свои данные
+ {% endif %} +
+
+
+ +
+ +
+
+{% endblock content %} diff --git a/apps/user/templates/user/notification-settings.html b/apps/user/templates/user/notification-settings.html index d66e7df2..f2688060 100644 --- a/apps/user/templates/user/notification-settings.html +++ b/apps/user/templates/user/notification-settings.html @@ -24,42 +24,25 @@ {% endif %}
-
+
{% csrf_token %}
Уведомления и рассылка
+ {% for category in subscription_categories %} - - - - - - + {% endfor %}
-
+
-{% endblock content %} \ No newline at end of file +{% endblock content %} diff --git a/apps/user/templates/user/profile-settings.html b/apps/user/templates/user/profile-settings.html index 0b830d91..8228c2ff 100644 --- a/apps/user/templates/user/profile-settings.html +++ b/apps/user/templates/user/profile-settings.html @@ -79,7 +79,16 @@ {% if form.email.errors %}
Укажите корректно свои данные
{% endif %} - + +
+
Телефон
+
+ +
+ {% if form.phone.errors %} +
Укажите корректно свои данные
+ {% endif %} +
ГОРОД
@@ -250,4 +259,11 @@ var openFile = function(file) { reader.readAsDataURL(input.files[0]); }; -{% endblock content %} \ No newline at end of file + +{% endblock content %} + +{% block foot %} + +{% endblock foot %} \ No newline at end of file diff --git a/apps/user/views.py b/apps/user/views.py index 69e42bef..d98bcc07 100644 --- a/apps/user/views.py +++ b/apps/user/views.py @@ -10,6 +10,7 @@ from django.conf import settings from django.contrib.auth import login from django.core.exceptions import ValidationError from django.shortcuts import render, reverse, redirect +from django.views import View from django.views.generic import DetailView, UpdateView, TemplateView, FormView from django.contrib import messages from django.contrib.auth import get_user_model @@ -25,8 +26,9 @@ from apps.course.models import Course from apps.notification.utils import send_email from apps.school.models import SchoolSchedule from apps.payment.models import AuthorBalance, CoursePayment, SchoolPayment +from apps.user.models import AuthorRequest, EmailSubscription, SubscriptionCategory -from .forms import UserEditForm, WithdrawalForm +from .forms import AuthorRequesForm, UserEditForm, WithdrawalForm User = get_user_model() @@ -85,6 +87,28 @@ class UserView(DetailView): return context +class SubscribeView(View): + + def post(self, request, pk=None, **kwargs): + refferer = request.META.get('HTTP_REFERER') + if request.user.is_authenticated: + messages.info(request, 'Вы уже подписаны на рассылки.') + return redirect(refferer) + email = request.POST.get('email', None) + if email: + email_subscription = EmailSubscription.objects.create( + email=email, + ) + email_subscription.categories.set( + SubscriptionCategory.objects.filter(auto_add=True) + ) + messages.info(request, 'Вы подписаны на новости.') + return redirect(refferer) + else: + messages.error(request, 'Введите адрес электронной почты.') + return redirect(refferer) + + @method_decorator(login_required, name='dispatch') class NotificationEditView(TemplateView): template_name = 'user/notification-settings.html' @@ -92,6 +116,18 @@ class NotificationEditView(TemplateView): def get(self, request, pk=None): return super().get(request) + def post(self, request, pk=None): + categories = request.POST.getlist('category', []) + request.user.email_subscription.categories.set( + SubscriptionCategory.objects.filter(id__in=categories) + ) + return redirect('user-edit-notifications', request.user.id) + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + context['subscription_categories'] = SubscriptionCategory.objects.all() + return context + @method_decorator(login_required, name='dispatch') class PaymentHistoryView(FormView): @@ -115,7 +151,7 @@ class PaymentHistoryView(FormView): type=AuthorBalance.OUT, amount=form.cleaned_data['amount'], status=AuthorBalance.PENDING, - card=form.cleaned_data['amount'], + card=form.cleaned_data['card'], ) return self.form_valid(form) else: @@ -182,3 +218,46 @@ class UserEditView(UpdateView): def get_success_url(self): return reverse('user-edit-profile', args=[self.object.id]) + + +class AuthorRequestView(FormView): + template_name = 'user/become-author.html' + form_class = AuthorRequesForm + success_url = reverse_lazy('author-request-success') + + def post(self, request, pk=None): + form = self.get_form() + if form.is_valid(): + if request.user.is_authenticated: + email = request.user.email + if request.user.role in [User.AUTHOR_ROLE, User.ADMIN_ROLE]: + messages.info(request, 'Вы уже являетесь автором') + return self.form_invalid(form) + else: + email = form.cleaned_data['email'] + + if AuthorRequest.objects.filter(email=email).exists(): + messages.error(request, 'Вы уже отправили заявку на преподавателя') + return self.form_invalid(form) + + AuthorRequest.objects.create( + first_name=form.cleaned_data['first_name'], + last_name=form.cleaned_data['last_name'], + email=email, + about=form.cleaned_data['about'], + facebook=form.cleaned_data['facebook'], + ) + return self.form_valid(form) + else: + return self.form_invalid(form) + + def get_context_data(self, **kwargs): + if self.request.user.is_authenticated: + self.initial = { + 'first_name': self.request.user.first_name, + 'last_name': self.request.user.last_name, + 'email': self.request.user.email, + 'about': self.request.user.about, + 'facebook': self.request.user.facebook, + } + return super().get_context_data(**kwargs) diff --git a/docker-compose.yml b/docker-compose.yml index ab168db4..d848728c 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -13,7 +13,7 @@ services: - "5432:5432" redis: - image: redis:3-alpine + image: redis:4-alpine ports: - "6379:6379" @@ -22,7 +22,7 @@ services: restart: always volumes: - .:/lilcity - command: bash -c "python manage.py migrate && python manage.py loaddata /lilcity/apps/*/fixtures/*.json && gunicorn --workers=4 project.wsgi --bind=0.0.0.0:8000 --worker-class=gthread --reload" + command: bash -c "python manage.py collectstatic --no-input && python manage.py migrate && python manage.py loaddata /lilcity/apps/*/fixtures/*.json && gunicorn --workers=4 project.wsgi --bind=0.0.0.0:8000 --worker-class=gthread --reload" environment: - DJANGO_SETTINGS_MODULE=project.settings - DATABASE_SERVICE_HOST=db diff --git a/project/context_processors.py b/project/context_processors.py new file mode 100644 index 00000000..cf8dc47a --- /dev/null +++ b/project/context_processors.py @@ -0,0 +1,5 @@ +from apps.config.models import Config + + +def config(request): + return {"config": Config.load()} diff --git a/project/fields.py b/project/fields.py new file mode 100644 index 00000000..a2e4709f --- /dev/null +++ b/project/fields.py @@ -0,0 +1,14 @@ +from django.forms import ImageField as BaseImageField + + +class ImageField(BaseImageField): + + def to_internal_value(self, data): + # if data is None image field was not uploaded + if data: + file_object = super(ImageField, self).to_internal_value(data) + django_field = self._DjangoImageField() + django_field.error_messages = self.error_messages + django_field.to_python(file_object) + return file_object + return data diff --git a/project/settings.py b/project/settings.py index 6860b3d0..2934c1de 100644 --- a/project/settings.py +++ b/project/settings.py @@ -11,6 +11,8 @@ https://docs.djangoproject.com/en/2.0/ref/settings/ """ import os +import raven + from celery.schedules import crontab from collections import OrderedDict from datetime import timedelta @@ -50,8 +52,8 @@ INSTALLED_APPS = [ 'rest_framework.authtoken', 'drf_yasg', 'corsheaders', - 'constance', - 'constance.backends.database', + 'sorl.thumbnail', + 'raven.contrib.django.raven_compat', ] + [ 'apps.auth.apps', 'apps.user', @@ -59,10 +61,9 @@ INSTALLED_APPS = [ 'apps.payment', 'apps.course', 'apps.content', + 'apps.config', 'apps.school', ] -if DEBUG: - INSTALLED_APPS += ['silk'] MIDDLEWARE = [ 'corsheaders.middleware.CorsMiddleware', @@ -73,9 +74,8 @@ MIDDLEWARE = [ 'django.contrib.auth.middleware.AuthenticationMiddleware', 'django.contrib.messages.middleware.MessageMiddleware', 'django.middleware.clickjacking.XFrameOptionsMiddleware', + 'apps.auth.middleware.TokenAuthLoginMiddleware', ] -if DEBUG: - MIDDLEWARE += ['silk.middleware.SilkyMiddleware'] ROOT_URLCONF = 'project.urls' @@ -85,32 +85,26 @@ TEMPLATES = [ 'DIRS': [ 'project', ], - 'APP_DIRS': True, 'OPTIONS': { 'context_processors': [ - 'constance.context_processors.config', + 'project.context_processors.config', 'django.template.context_processors.debug', 'django.template.context_processors.request', 'django.contrib.auth.context_processors.auth', 'django.contrib.messages.context_processors.messages', ], + 'loaders': [ + ('django.template.loaders.cached.Loader', [ + 'django.template.loaders.filesystem.Loader', + 'django.template.loaders.app_directories.Loader', + ]), + ], }, }, ] WSGI_APPLICATION = 'project.wsgi.application' - -# Database -# https://docs.djangoproject.com/en/2.0/ref/settings/#databases - -# DATABASES = { -# 'default': { -# 'ENGINE': 'django.db.backends.sqlite3', -# 'NAME': os.path.join(BASE_DIR, 'db.sqlite3'), -# } -# } - DATABASES = { 'default': { 'ENGINE': 'django.db.backends.postgresql', @@ -202,6 +196,7 @@ REST_FRAMEWORK = { ), 'DEFAULT_RENDERER_CLASSES': ( 'rest_framework.renderers.JSONRenderer', + # 'rest_framework.renderers.BrowsableAPIRenderer', ), 'DEFAULT_FILTER_BACKENDS': ( 'django_filters.rest_framework.DjangoFilterBackend', @@ -221,46 +216,11 @@ CELERY_TASK_SERIALIZER = 'json' CELERY_BEAT_SCHEDULE = { 'retrieve_photos_from_instagram': { 'task': 'apps.content.tasks.retrieve_photos', - 'schedule': timedelta(minutes=2) if DEBUG else crontab(minute=0, hour=0), + 'schedule': timedelta(minutes=5) if DEBUG else crontab(minute=0, hour=0), 'args': (), }, } -# Dynamic settings - -CONSTANCE_BACKEND = 'constance.backends.database.DatabaseBackend' -CONSTANCE_CONFIG = OrderedDict(( - ('INSTAGRAM_CLIENT_ACCESS_TOKEN', ('7145314808.f6fa114.ce354a5d876041fc9d3db04b0045587d', '')), - ('INSTAGRAM_CLIENT_SECRET', ('2334a921425140ccb180d145dcd35b25', '')), - ('INSTAGRAM_PROFILE_URL', ('#', 'URL профиля Instagram.')), - ('INSTAGRAM_RESULTS_TAG', ('#lil_акварель', 'Тэг результатов работ.')), - ('INSTAGRAM_RESULTS_PATH', ('media/instagram/results/', 'Путь до результатов работ.')), - ('SERVICE_COMMISSION', (10, 'Комиссия сервиса в процентах.')), - ('SERVICE_DISCOUNT_MIN_AMOUNT', (3500, 'Минимальная сумма платежа для школы, после которой вычитывается скидка SERVICE_DISCOUNT.')), - ('SERVICE_DISCOUNT', (1000, 'Комиссия сервиса при покупке всех дней.')), -)) - -CONSTANCE_CONFIG_FIELDSETS = OrderedDict({ - 'Service': ( - 'SERVICE_COMMISSION', - 'SERVICE_DISCOUNT_MIN_AMOUNT', - 'SERVICE_DISCOUNT', - ), - 'Instagram': ( - 'INSTAGRAM_CLIENT_ACCESS_TOKEN', - 'INSTAGRAM_CLIENT_SECRET', - 'INSTAGRAM_PROFILE_URL', - 'INSTAGRAM_RESULTS_TAG', - 'INSTAGRAM_RESULTS_PATH', - ), -}) - - -try: - from .local_settings import * -except ImportError: - pass - try: from paymentwall import * except ImportError: @@ -270,6 +230,9 @@ else: Paymentwall.set_app_key('d6f02b90cf6b16220932f4037578aff7') Paymentwall.set_secret_key('4ea515bf94e34cf28646c2e12a7b8707') +# Mixpanel settings +MIX_TOKEN = '79bd6bfd98667ed977737e6810b8abcd' + # CORS settings if DEBUG: @@ -280,3 +243,18 @@ if DEBUG: SWAGGER_SETTINGS = { 'DOC_EXPANSION': 'none', } + +# Raven settings +RAVEN_CONFIG = { + 'dsn': 'https://bff536c4d71c4166afb91f83b9f73d55:ca47ad791a53480b9d40a85a26abf141@sentry.io/306843', + # If you are using git, you can also automatically configure the + # release based on the git info. + 'release': raven.fetch_git_sha(BASE_DIR), +} + +INSTAGRAM_RESULTS_PATH = 'media/instagram/results/' + +try: + from .local_settings import * +except ImportError: + pass diff --git a/project/templates/lilcity/edit_index.html b/project/templates/lilcity/edit_index.html index 0c2fc067..b5e79164 100644 --- a/project/templates/lilcity/edit_index.html +++ b/project/templates/lilcity/edit_index.html @@ -278,7 +278,7 @@
-
Выбор урока/дня
+
Выбор курса/дня
При записи на 5 уроков скидка 10%.
@@ -345,6 +345,10 @@
+ {% block foot %}{% endblock foot %} diff --git a/project/templates/lilcity/index.html b/project/templates/lilcity/index.html index 2f28628d..a39fb3b8 100644 --- a/project/templates/lilcity/index.html +++ b/project/templates/lilcity/index.html @@ -47,12 +47,23 @@ viewportmeta.content = 'width=device-width, maximum-scale=1.6, initial-scale=1.0'; } } - + {% block mixpanel %} + + + + {% endblock mixpanel %} @@ -120,7 +131,7 @@ {% endcomment %}
-
ВИДЕО-КУРСЫ +
ВИДЕОКУРСЫ
{% category_menu_items category %}
@@ -188,8 +199,11 @@
{% if course_items %} diff --git a/project/templates/lilcity/school_schedules.html b/project/templates/lilcity/school_schedules.html new file mode 100644 index 00000000..88601188 --- /dev/null +++ b/project/templates/lilcity/school_schedules.html @@ -0,0 +1,52 @@ +{% load static %} + + + + + + + + + + + +
+
+ +
Расписание
+
+
+ {% for school_schedule in school_schedules %} +
+
{{ school_schedule }}
+
+
{{ school_schedule.title }}
+
{{ school_schedule.description }}
+
+ +
{{ school_schedule.materials }}
+
+
+
+ {% endfor %} +
+ {% comment %}
+ Распечатать расписание чтобы не забыть +
{% endcomment %} +
+
+ + + + diff --git a/project/urls.py b/project/urls.py index 30a14cbd..f1d97216 100644 --- a/project/urls.py +++ b/project/urls.py @@ -13,30 +13,40 @@ Including another URLconf 1. Import the include() function: from django.urls import include, path 2. Add a URL to urlpatterns: path('blog/', include('blog.urls')) """ +from django.conf import settings from django.contrib import admin -from django.urls import path, include from django.views.generic import TemplateView -from django.conf import settings +from django.urls import path, include + from apps.course.views import ( CoursesView, likes, coursecomment, CourseView, LessonView, SearchView, lessoncomment, CourseEditView, - CourseOnModerationView, + CourseOnModerationView, CourseLiveEditView, ) from apps.user.views import ( - UserView, UserEditView, NotificationEditView, + AuthorRequestView, UserView, + UserEditView, NotificationEditView, PaymentHistoryView, resend_email_verify, + SubscribeView, +) +from apps.payment.views import ( + CourseBuySuccessView, CourseBuyView, + PaymentwallCallbackView, SchoolBuySuccessView, + SchoolBuyView, ) -from apps.payment.views import CourseBuyView, PaymentwallCallbackView, SchoolBuyView -from .views import IndexView +from .views import IndexView, SchoolSchedulesView urlpatterns = [ path('admin/', admin.site.urls), path('auth/', include(('apps.auth.urls', 'lilcity'))), + path('author-request/', AuthorRequestView.as_view(), name='author_request'), + path('author-request/success/', TemplateView.as_view(template_name='user/become-author-success.html'), name='author-request-success'), path('courses/', CoursesView.as_view(), name='courses'), path('course/create', CourseEditView.as_view(), name='course_create'), + path('course/create/live', CourseLiveEditView.as_view(), name='course_create_live'), path('course/on-moderation', CourseOnModerationView.as_view(), name='course-on-moderation'), path('course//', CourseView.as_view(), name='course'), path('course//', CourseView.as_view(), name='course'), @@ -49,7 +59,8 @@ urlpatterns = [ path('lesson//comment', lessoncomment, name='lessoncomment'), path('payments/ping', PaymentwallCallbackView.as_view(), name='payment-ping'), path('paymentwall/pingback', PaymentwallCallbackView.as_view(), name='payment-ping-second'), - path('payments/success', TemplateView.as_view(template_name='payment/payment_success.html'), name='payment-success'), + path('payments/course//success', CourseBuySuccessView.as_view(), name='course_payment_success'), + path('payments/school/success', SchoolBuySuccessView.as_view(), name='payment-success'), path('payments/error', TemplateView.as_view(template_name='payment/payment_error.html'), name='payment-error'), path('school/checkout', SchoolBuyView.as_view(), name='school-checkout'), path('search/', SearchView.as_view(), name='search'), @@ -58,12 +69,14 @@ urlpatterns = [ path('user//notifications', NotificationEditView.as_view(), name='user-edit-notifications'), path('user//payments', PaymentHistoryView.as_view(), name='user-edit-payments'), path('user/resend-email-verify', resend_email_verify, name='resend-email-verify'), - path('privacy', TemplateView.as_view(template_name="templates/lilcity/privacy_policy.html"), name='privacy'), - path('terms', TemplateView.as_view(template_name="templates/lilcity/terms.html"), name='terms'), - path('refund-policy', TemplateView.as_view(template_name="templates/lilcity/refund_policy.html"), name='refund_policy'), + path('subscribe', SubscribeView.as_view(), name='subscribe'), + path('privacy', TemplateView.as_view(template_name='templates/lilcity/privacy_policy.html'), name='privacy'), + path('terms', TemplateView.as_view(template_name='templates/lilcity/terms.html'), name='terms'), + path('refund-policy', TemplateView.as_view(template_name='templates/lilcity/refund_policy.html'), name='refund_policy'), + path('school-schedules', SchoolSchedulesView.as_view(), name='school_schedules'), path('', IndexView.as_view(), name='index'), path('api/v1/', include(('api.v1.urls', 'api_v1'))), - path('test', TemplateView.as_view(template_name="templates/lilcity/test.html"), name='test'), + path('test', TemplateView.as_view(template_name='templates/lilcity/test.html'), name='test'), ] @@ -71,6 +84,8 @@ if settings.DEBUG: from django.conf.urls.static import static from django.contrib.staticfiles.urls import staticfiles_urlpatterns - urlpatterns += [path('silk/', include('silk.urls', namespace='silk'))] urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT) urlpatterns += staticfiles_urlpatterns() + + if 'silk' in settings.INSTALLED_APPS: + urlpatterns += [path('silk/', include('silk.urls', namespace='silk'))] diff --git a/project/views.py b/project/views.py index 9dafb0a4..6f73fff7 100644 --- a/project/views.py +++ b/project/views.py @@ -1,8 +1,12 @@ +from django.db.models import Min +from django.contrib.auth import get_user_model from django.views.generic import TemplateView from apps.course.models import Course from apps.school.models import SchoolSchedule +User = get_user_model() + class IndexView(TemplateView): template_name = 'templates/lilcity/main.html' @@ -12,5 +16,16 @@ class IndexView(TemplateView): context.update({ 'course_items': Course.objects.filter(status=Course.PUBLISHED)[:3], 'school_schedules': SchoolSchedule.objects.all(), + 'min_school_price': SchoolSchedule.objects.all().aggregate(Min('month_price'))['month_price__min'], + 'authors': User.objects.filter(role=User.AUTHOR_ROLE, show_in_mainpage=True), }) return context + + +class SchoolSchedulesView(TemplateView): + template_name = 'templates/lilcity/school_schedules.html' + + def get_context_data(self): + context = super().get_context_data() + context['school_schedules'] = SchoolSchedule.objects.all() + return context diff --git a/requirements.txt b/requirements.txt index 2b469cf2..a84ea848 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,24 +1,27 @@ # Python-3.6 -gunicorn==19.7.1 -requests==2.18.4 -Django==2.0.2 -django-anymail[mailgun]==1.2 -# paymentwall-python==1.0.7 -git+https://github.com/ivlevdenis/paymentwall-python.git -twilio==6.10.0 -psycopg2==2.7.3.2 -facepy==1.0.9 -Pillow==5.0.0 -django-active-link==0.1.2 arrow==0.12.1 +celery[redis]==4.1.0 +Django==2.0.3 +django-active-link==0.1.2 +django-anymail[mailgun]==2.0 +django-cors-headers==2.2.0 django-filter==2.0.0.dev1 django-mptt==0.9.0 +django-silk==2.0.0 +django-phonenumber-field==2.0.0 django-polymorphic-tree==1.5 -celery[redis]==4.1.0 djangorestframework==3.7.7 -drf-yasg[validation]==1.4.0 -django-silk==2.0.0 -django-cors-headers==2.1.0 -django-constance[database]==2.1.0 +drf-yasg[validation]==1.5.0 +facepy==1.0.9 +gunicorn==19.7.1 +mixpanel==4.3.2 +psycopg2-binary==2.7.4 +Pillow==5.0.0 +raven==6.6.0 +requests==2.18.4 +sorl-thumbnail==12.4.1 +twilio==6.10.5 +# paymentwall-python==1.0.7 +git+https://github.com/ivlevdenis/paymentwall-python.git # python-instagram==1.3.2 git+https://github.com/ivlevdenis/python-instagram.git diff --git a/web/build/img/sprite.svg b/web/build/img/sprite.svg index 2853f1c8..01c98f63 100644 --- a/web/build/img/sprite.svg +++ b/web/build/img/sprite.svg @@ -1,4 +1,6 @@ + + @@ -24,6 +26,11 @@ + + + + + diff --git a/web/build/index.html b/web/build/index.html index 38f1efe0..a1dbb725 100755 --- a/web/build/index.html +++ b/web/build/index.html @@ -87,7 +87,7 @@ -
ВИДЕО-КУРСЫ +
ВИДЕОКУРСЫ -
ВИДЕО-КУРСЫ + -
+
КАТЕГОРИЯ
@@ -72,7 +72,22 @@
{{ courseFullUrl }}
-
+ +
+
ССЫЛКА НА VIMEO
+
+ +
+
+ +
+
ДАТА
+
+ +
+
+ +
ДОСТУП
-
-
+
ЗАПУСК
@@ -135,11 +150,17 @@ v-model="course.short_description">
+
-
+
-
- - - - +
+ - -
+ + + + +
+ @@ -257,10 +280,15 @@ import $ from 'jquery'; import {required, minValue, numeric } from 'vuelidate/lib/validators' import slugify from 'slugify'; + import Draggable from 'vuedraggable'; + import {showNotification} from "../js/modules/notification"; + import createHistory from "history/createBrowserHistory"; + + const history = createHistory(); export default { name: "course-redactor", - props: ["authorName", "authorPicture", "accessToken", "courseId"], + props: ["authorName", "authorPicture", "accessToken", "courseId", "live"], data() { return { viewSection: 'course', @@ -335,6 +363,25 @@ 'value': '18:00', } ], + + dateOptions: [ + { + 'title': 'Акварельс (Понедельник, 2 апр)', + 'value': 'Акварельс (Понедельник, 2 апр)', + }, + { + 'title': 'Рельсотрон (Вторник, 3 апр)', + 'value': 'Рельсотрон (Вторник, 3 апр)', + }, + { + 'title': 'Коломёт (Среда, 4 апр)', + 'value': 'Коломёт (Среда, 4 апр)', + }, + { + 'title': 'Зиккурат (Четверг, 5 апр)', + 'value': 'Зиккурат (Четверг, 5 апр)', + }, + ], showErrors: false, savingTimeout: null, savingDebounceTimeout: null, @@ -422,9 +469,21 @@ }, editLesson(lessonIndex) { this.currentLesson = this.lessons[lessonIndex]; + if (this.viewSection !== 'lessons-edit') { + history.push("/course/create/lessons/new"); + } this.viewSection = 'lessons-edit'; }, + showCourse() { + if (this.viewSection !== 'course') { + history.push("/course/create"); + } + this.viewSection = 'course' + }, showLessons() { + if (this.viewSection !== 'lessons') { + history.push("/course/create/lessons"); + } this.viewSection = 'lessons'; }, addLesson() { @@ -434,6 +493,9 @@ course_id: this.course.id, content: [], }; + if (this.viewSection !== 'lessons-edit') { + history.push("/course/create/lessons/new"); + } this.viewSection = 'lessons-edit'; window.scrollTo(0, 0); }, @@ -515,6 +577,7 @@ onCoursePublish() { this.showErrors = true; if (this.$v.$invalid) { + showNotification("error", "Заполните все необходимые поля"); return; } const publishButton = $('#course-redactor__publish-button'); @@ -551,7 +614,7 @@ clearTimeout(this.savingTimeout); document.getElementById('course-redactor__saving-status').innerText = 'СОХРАНЕНИЕ...'; const courseObject = this.course; - courseObject.url = slugify(courseObject.url); + courseObject.url = (courseObject.url) ? slugify(courseObject.url):courseObject.url; api.saveCourse(courseObject, this.accessToken) .then((response) => { this.courseSaving = false; @@ -567,7 +630,17 @@ if (this.course.is_deferred) { courseData.is_deferred = true; } - this.course = courseData; + let remoteUUIDMapper = {} + if (courseData.content) { + courseData.content.forEach((contentElement) => { + remoteUUIDMapper[contentElement.uuid] = contentElement.data.id + }) + } + this.course.content.forEach((contentElement, index) => { + if (!contentElement.data.id) { + this.$set(this.course.content[index].data, 'id', remoteUUIDMapper[contentElement.uuid]) + } + }) if (courseData.url) { this.slugChanged = true; } @@ -579,10 +652,22 @@ this.courseSyncHook = false; this.courseSaving = false; }); - }, 2000); + }, 3000); } }, mounted() { + console.log('live', this.live); + // Listen for changes to the current location. + this.unlisten = history.listen((location, action) => { + if (location.pathname === '/course/create/lessons') { + this.viewSection = 'lessons'; + } else if (location.pathname === '/course/create') { + this.viewSection = 'course'; + } else if (location.pathname === '/course/create/lessons/new') { + this.viewSection = 'lessons-edit'; + } + }); + api.getCategories(this.accessToken) .then((response) => { if (response.data) { @@ -629,10 +714,10 @@ }, displayPrice: { get: function () { - return this.course.is_paid ? this.course.price : 0; + return this.course.is_paid ? (this.course.price || 0) : 0; }, set: function (value) { - this.course.price = value; + this.course.price = value || 0; } }, categorySelect: { @@ -677,6 +762,9 @@ return `https://lil.city/course/${suffix}`; }, }, + beforeDestroy() { + this.unlisten(); + }, watch: { 'course': { handler: function (newValue, oldValue) { @@ -707,6 +795,7 @@ 'block-images': BlockImages, 'block-video': BlockVideo, 'lesson-redactor': LessonRedactor, + 'vue-draggable': Draggable, } } @@ -776,4 +865,23 @@ width: 140px; height: 140px; } - \ No newline at end of file + + .kit__section-remove { + button.sortable__handle { + margin-right: 10px; + cursor: -webkit-grab; + cursor: grab; + + svg.icon-hamburger { + width: 1em; + height: 1em; + } + } + } + + .sortable-ghost, .sortable-chosen { + background: white; + border-radius: 10px; + } + + diff --git a/web/src/components/blocks/BlockAdd.vue b/web/src/components/blocks/BlockAdd.vue index 7c06a641..0090a8df 100644 --- a/web/src/components/blocks/BlockAdd.vue +++ b/web/src/components/blocks/BlockAdd.vue @@ -51,6 +51,8 @@ \ No newline at end of file + diff --git a/web/src/icons/hamburger.svg b/web/src/icons/hamburger.svg new file mode 100644 index 00000000..b5866b36 --- /dev/null +++ b/web/src/icons/hamburger.svg @@ -0,0 +1,18 @@ + + + + + + + + + + + + + + + + + + diff --git a/web/src/js/app.js b/web/src/js/app.js index 81888f5c..16651100 100644 --- a/web/src/js/app.js +++ b/web/src/js/app.js @@ -13,5 +13,7 @@ import "./modules/courses"; import "./modules/comments"; import "./modules/password-show"; import "./modules/profile"; +import "./modules/notification"; +import "./modules/mixpanel"; import "../sass/app.sass"; diff --git a/web/src/js/modules/api.js b/web/src/js/modules/api.js index 2cfc5385..82d42754 100644 --- a/web/src/js/modules/api.js +++ b/web/src/js/modules/api.js @@ -83,6 +83,7 @@ export const api = { 'type': 'text', 'data': { 'id': block.data.id ? block.data.id : null, + 'uuid': block.uuid, 'position': ++index, 'title': block.data.title, 'txt': block.data.text, @@ -93,6 +94,7 @@ export const api = { 'type': 'image', 'data': { 'id': block.data.id ? block.data.id : null, + 'uuid': block.uuid, 'position': ++index, 'title': block.data.title, 'img': block.data.image_id, @@ -103,6 +105,7 @@ export const api = { 'type': 'image-text', 'data': { 'id': block.data.id ? block.data.id : null, + 'uuid': block.uuid, 'position': ++index, 'title': block.data.title, 'img': block.data.image_id, @@ -114,6 +117,7 @@ export const api = { 'type': 'images', 'data': { 'id': block.data.id ? block.data.id : null, + 'uuid': block.uuid, 'position': ++index, 'title': block.data.title, 'images': block.data.images.map((galleryImage) => { @@ -129,6 +133,7 @@ export const api = { 'type': 'video', 'data': { 'id': block.data.id ? block.data.id : null, + 'uuid': block.uuid, 'position': ++index, 'title': block.data.title, 'url': block.data.video_url, @@ -254,6 +259,7 @@ export const api = { coverImageId: courseJSON.cover && courseJSON.cover.id ? courseJSON.cover.id : null, coverImage: courseJSON.cover && courseJSON.cover.image ? courseJSON.cover.image : null, content: api.convertContentResponse(courseJSON.content), + gallery: {images: courseJSON.gallery.gallery_images}, } }, convertContentResponse: (contentJson) => { @@ -269,6 +275,7 @@ export const api = { if (contentItem.type === 'text') { return { 'type': 'text', + 'uuid': contentItem.uuid, 'data': { 'id': contentItem.id ? contentItem.id : null, 'title': contentItem.title, @@ -278,6 +285,7 @@ export const api = { } else if (contentItem.type === 'image') { return { 'type': 'image', + 'uuid': contentItem.uuid, 'data': { 'id': contentItem.id ? contentItem.id : null, 'title': contentItem.title, @@ -288,6 +296,7 @@ export const api = { } else if (contentItem.type === 'image-text') { return { 'type': 'image-text', + 'uuid': contentItem.uuid, 'data': { 'id': contentItem.id ? contentItem.id : null, 'title': contentItem.title, @@ -299,6 +308,7 @@ export const api = { } else if (contentItem.type === 'images') { return { 'type': 'images', + 'uuid': contentItem.uuid, 'data': { 'id': contentItem.id ? contentItem.id : null, 'title': contentItem.title, @@ -314,6 +324,7 @@ export const api = { } else if (contentItem.type === 'video') { return { 'type': 'video', + 'uuid': contentItem.uuid, 'data': { 'id': contentItem.id ? contentItem.id : null, 'title': contentItem.title, @@ -407,4 +418,4 @@ export const api = { } }); } -}; \ No newline at end of file +}; diff --git a/web/src/js/modules/auth.js b/web/src/js/modules/auth.js index dcda0d5e..3ec8780e 100644 --- a/web/src/js/modules/auth.js +++ b/web/src/js/modules/auth.js @@ -41,7 +41,7 @@ $(document).ready(function () { pass.hide(); login.fadeIn(); }); - + $('#password-reset__success-hide').on('click', function (e) { e.preventDefault(); $('#password-reset__form-wrapper').show(); @@ -265,36 +265,45 @@ $(document).ready(function () { }); $.ajaxSetup({cache: true}); - $.getScript('https://connect.facebook.net/en_US/sdk.js'); + load_facebook(); const facebookButton = $('button.btn_fb'); facebookButton.on('click', function () { $('.auth-register__common-error').hide(); facebookButton.addClass('loading'); - $.getScript('https://connect.facebook.net/en_US/sdk.js', function () { - FB.init({ - appId: '161924711105785', - version: 'v2.7' - }); - FB.getLoginStatus(function (response) { - if (response.status === 'connected') { - login_with_facebook(response.authResponse.accessToken); - } - else { - FB.login(function (response) { - if (response.status === 'connected') { - login_with_facebook(response.authResponse.accessToken); - } else { - facebookButton.removeClass('loading'); - $('.auth-register__common-error').text('Не удалось авторизоваться через Facebook'); - } - }, {scope: 'public_profile,email'}); - } - }); - }); + + if (facebookResponse) { + if (facebookResponse.status === 'connected') { + login_with_facebook(facebookResponse.authResponse.accessToken); + return; + } + } + + FB.login(function (response) { + if (response.status === 'connected') { + login_with_facebook(response.authResponse.accessToken); + } else { + facebookButton.removeClass('loading'); + $('.auth-register__common-error').text('Не удалось авторизоваться через Facebook'); + } + }, {scope: 'public_profile,email'}); }); }); +let facebookResponse; + +function load_facebook() { + $.getScript('https://connect.facebook.net/en_US/sdk.js', function () { + FB.init({ + appId: '161924711105785', + version: 'v2.7' + }); + FB.getLoginStatus(function (response) { + facebookResponse = response; + }); + }); +} + function login_with_facebook(accessToken) { $.ajax('/auth/facebook_login/', { method: 'POST', @@ -316,4 +325,4 @@ function login_with_facebook(accessToken) { .always(function () { $('button.btn_fb').removeClass('loading'); }); -} \ No newline at end of file +} diff --git a/web/src/js/modules/comments.js b/web/src/js/modules/comments.js index 83f810ab..30b0a815 100644 --- a/web/src/js/modules/comments.js +++ b/web/src/js/modules/comments.js @@ -22,7 +22,7 @@ $(document).ready(function () { .done(function (data) { if (data.success === true) { if (replyToValue > 0) { - $(`#question__${replyToValue}`).after(data.comment); + $(`#question__replyto__${replyToValue}`).after(data.comment); } else { $('.questions__list').append(data.comment); } diff --git a/web/src/js/modules/common.js b/web/src/js/modules/common.js index ecbd4dee..dea36989 100644 --- a/web/src/js/modules/common.js +++ b/web/src/js/modules/common.js @@ -1,8 +1,14 @@ import $ from 'jquery'; import Inputmask from "inputmask"; import SmoothScroll from 'smooth-scroll/dist/js/smooth-scroll'; +import baguetteBox from 'baguettebox.js' + window.Inputmask = Inputmask; +window.baguetteBox = baguetteBox; + $(document).ready(function () { + + baguetteBox.run('.gallery'); // Добавляем заголовок X-CSRFToken для всех AJAX запросов JQuery. $.ajaxSetup({ headers: { diff --git a/web/src/js/modules/courses.js b/web/src/js/modules/courses.js index 90ffeb97..2581df46 100644 --- a/web/src/js/modules/courses.js +++ b/web/src/js/modules/courses.js @@ -17,7 +17,9 @@ $(document).ready(function () { $('div[data-future-course]').each((_, element) => { const courseTime = parseInt($(element).attr('data-future-course-time')) + LIL_SERVER_TIME_DIFF; const relativeTimeString = moment(courseTime, 'X').fromNow(); + $(element).find('div.courses__time').text(relativeTimeString); + $(element).find('div.video__time').text(relativeTimeString); }); }, 1000); @@ -31,7 +33,7 @@ $(document).ready(function () { e.preventDefault(); const currentCategory = $(this).attr('data-category-name'); $('[data-category-name]').removeClass('active'); - $(`[data-category-name=${currentCategory}]`).addClass('active'); + $(`[data-category-name='${currentCategory}']`).addClass('active'); history.replace($(this).attr('data-category-url')); load_courses($(this).attr('data-category-url'), true); }); diff --git a/web/src/js/modules/mixpanel.js b/web/src/js/modules/mixpanel.js new file mode 100644 index 00000000..bfa7040e --- /dev/null +++ b/web/src/js/modules/mixpanel.js @@ -0,0 +1,36 @@ +import $ from 'jquery'; + +$(document).ready(function (e) { + if (typeof mixpanel != 'undefined') { + mixpanel.identify(USER_ID); + let body = $('body'), + cource = $('.course'); + + if (cource.length) { + mixpanel.track( + 'Open course', + { 'course_id': COURSE_ID } + ); + }; + + body.on('click', '[data-popup]', function (e) { + let data = $(this).data('popup'); + if (data === '.js-popup-buy') { + mixpanel.track( + 'Open school buy popup' + ); + } + }); + body.on('click', '[data-course-buy]', function (e) { + e.preventDefault(); + let href = $(this).attr('href'); + let t = mixpanel.track( + 'Click course buy button', + { 'course_id': COURSE_ID }, + function () { + window.location = href; + } + ); + }); + } +}); diff --git a/web/src/js/modules/notification.js b/web/src/js/modules/notification.js new file mode 100644 index 00000000..7e614802 --- /dev/null +++ b/web/src/js/modules/notification.js @@ -0,0 +1,14 @@ +import $ from 'jquery'; +import '../../sass/components/notification.scss'; + +export function showNotification(style, text) { + let htmlNode = document.createElement('div'); + let htmlElement = $(htmlNode).addClass('notification').addClass(`notification--${style}`).text(text).appendTo($('body')); + + setTimeout(() => { + htmlElement.fadeOut(400, () => { + htmlElement.remove(); + }) + }, 3500); +} + diff --git a/web/src/js/modules/popup.js b/web/src/js/modules/popup.js index db9824b1..feaa8a8a 100644 --- a/web/src/js/modules/popup.js +++ b/web/src/js/modules/popup.js @@ -73,7 +73,7 @@ $(document).ready(function () { } var text = ''; - if(weekdays.length >= 7) { + if(schoolAmountForDiscount <= price) { text = ''+price+' '+(price-schoolDiscount)+'р.'; } else { text = price+'p.'; diff --git a/web/src/js/third_party/mixpanel-2-latest.js b/web/src/js/third_party/mixpanel-2-latest.js new file mode 100644 index 00000000..ddf58895 --- /dev/null +++ b/web/src/js/third_party/mixpanel-2-latest.js @@ -0,0 +1,5679 @@ +(function () { + 'use strict'; + + var Config = { + DEBUG: false, + LIB_VERSION: '2.19.0' + }; + + // since es6 imports are static and we run unit tests from the console, window won't be defined when importing this file + var win; + if (typeof(window) === 'undefined') { + var loc = { + hostname: '' + }; + win = { + navigator: { userAgent: '' }, + document: { + location: loc, + referrer: '' + }, + screen: { width: 0, height: 0 }, + location: loc + }; + } else { + win = window; + } + + + + /* + * Saved references to long variable names, so that closure compiler can + * minimize file size. + */ + + var ArrayProto = Array.prototype; + var FuncProto = Function.prototype; + var ObjProto = Object.prototype; + var slice = ArrayProto.slice; + var toString = ObjProto.toString; + var hasOwnProperty = ObjProto.hasOwnProperty; + var windowConsole = win.console; + var navigator$1 = win.navigator; + var document$1 = win.document; + var windowOpera = win.opera; + var screen = win.screen; + var userAgent = navigator$1.userAgent; + var nativeBind = FuncProto.bind; + var nativeForEach = ArrayProto.forEach; + var nativeIndexOf = ArrayProto.indexOf; + var nativeIsArray = Array.isArray; + var breaker = {}; + var _ = { + trim: function(str) { + // https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/String/Trim#Polyfill + return str.replace(/^[\s\uFEFF\xA0]+|[\s\uFEFF\xA0]+$/g, ''); + } + }; + + // Console override + var console$1 = { + /** @type {function(...[*])} */ + log: function() { + if (Config.DEBUG && !_.isUndefined(windowConsole) && windowConsole) { + try { + windowConsole.log.apply(windowConsole, arguments); + } catch (err) { + _.each(arguments, function(arg) { + windowConsole.log(arg); + }); + } + } + }, + /** @type {function(...[*])} */ + error: function() { + if (Config.DEBUG && !_.isUndefined(windowConsole) && windowConsole) { + var args = ['Mixpanel error:'].concat(_.toArray(arguments)); + try { + windowConsole.error.apply(windowConsole, args); + } catch (err) { + _.each(args, function(arg) { + windowConsole.error(arg); + }); + } + } + }, + /** @type {function(...[*])} */ + critical: function() { + if (!_.isUndefined(windowConsole) && windowConsole) { + var args = ['Mixpanel error:'].concat(_.toArray(arguments)); + try { + windowConsole.error.apply(windowConsole, args); + } catch (err) { + _.each(args, function(arg) { + windowConsole.error(arg); + }); + } + } + } + }; + + + // UNDERSCORE + // Embed part of the Underscore Library + _.bind = function(func, context) { + var args, bound; + if (nativeBind && func.bind === nativeBind) { + return nativeBind.apply(func, slice.call(arguments, 1)); + } + if (!_.isFunction(func)) { + throw new TypeError(); + } + args = slice.call(arguments, 2); + bound = function() { + if (!(this instanceof bound)) { + return func.apply(context, args.concat(slice.call(arguments))); + } + var ctor = {}; + ctor.prototype = func.prototype; + var self = new ctor(); + ctor.prototype = null; + var result = func.apply(self, args.concat(slice.call(arguments))); + if (Object(result) === result) { + return result; + } + return self; + }; + return bound; + }; + + _.bind_instance_methods = function(obj) { + for (var func in obj) { + if (typeof(obj[func]) === 'function') { + obj[func] = _.bind(obj[func], obj); + } + } + }; + + /** + * @param {*=} obj + * @param {function(...[*])=} iterator + * @param {Object=} context + */ + _.each = function(obj, iterator, context) { + if (obj === null || obj === undefined) { + return; + } + if (nativeForEach && obj.forEach === nativeForEach) { + obj.forEach(iterator, context); + } else if (obj.length === +obj.length) { + for (var i = 0, l = obj.length; i < l; i++) { + if (i in obj && iterator.call(context, obj[i], i, obj) === breaker) { + return; + } + } + } else { + for (var key in obj) { + if (hasOwnProperty.call(obj, key)) { + if (iterator.call(context, obj[key], key, obj) === breaker) { + return; + } + } + } + } + }; + + _.escapeHTML = function(s) { + var escaped = s; + if (escaped && _.isString(escaped)) { + escaped = escaped + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); + } + return escaped; + }; + + _.extend = function(obj) { + _.each(slice.call(arguments, 1), function(source) { + for (var prop in source) { + if (source[prop] !== void 0) { + obj[prop] = source[prop]; + } + } + }); + return obj; + }; + + _.isArray = nativeIsArray || function(obj) { + return toString.call(obj) === '[object Array]'; + }; + + // from a comment on http://dbj.org/dbj/?p=286 + // fails on only one very rare and deliberate custom object: + // var bomb = { toString : undefined, valueOf: function(o) { return "function BOMBA!"; }}; + _.isFunction = function(f) { + try { + return /^\s*\bfunction\b/.test(f); + } catch (x) { + return false; + } + }; + + _.isArguments = function(obj) { + return !!(obj && hasOwnProperty.call(obj, 'callee')); + }; + + _.toArray = function(iterable) { + if (!iterable) { + return []; + } + if (iterable.toArray) { + return iterable.toArray(); + } + if (_.isArray(iterable)) { + return slice.call(iterable); + } + if (_.isArguments(iterable)) { + return slice.call(iterable); + } + return _.values(iterable); + }; + + _.keys = function(obj) { + var results = []; + if (obj === null) { + return results; + } + _.each(obj, function(value, key) { + results[results.length] = key; + }); + return results; + }; + + _.values = function(obj) { + var results = []; + if (obj === null) { + return results; + } + _.each(obj, function(value) { + results[results.length] = value; + }); + return results; + }; + + _.identity = function(value) { + return value; + }; + + _.include = function(obj, target) { + var found = false; + if (obj === null) { + return found; + } + if (nativeIndexOf && obj.indexOf === nativeIndexOf) { + return obj.indexOf(target) != -1; + } + _.each(obj, function(value) { + if (found || (found = (value === target))) { + return breaker; + } + }); + return found; + }; + + _.includes = function(str, needle) { + return str.indexOf(needle) !== -1; + }; + + // Underscore Addons + _.inherit = function(subclass, superclass) { + subclass.prototype = new superclass(); + subclass.prototype.constructor = subclass; + subclass.superclass = superclass.prototype; + return subclass; + }; + + _.isObject = function(obj) { + return (obj === Object(obj) && !_.isArray(obj)); + }; + + _.isEmptyObject = function(obj) { + if (_.isObject(obj)) { + for (var key in obj) { + if (hasOwnProperty.call(obj, key)) { + return false; + } + } + return true; + } + return false; + }; + + _.isUndefined = function(obj) { + return obj === void 0; + }; + + _.isString = function(obj) { + return toString.call(obj) == '[object String]'; + }; + + _.isDate = function(obj) { + return toString.call(obj) == '[object Date]'; + }; + + _.isNumber = function(obj) { + return toString.call(obj) == '[object Number]'; + }; + + _.isElement = function(obj) { + return !!(obj && obj.nodeType === 1); + }; + + _.encodeDates = function(obj) { + _.each(obj, function(v, k) { + if (_.isDate(v)) { + obj[k] = _.formatDate(v); + } else if (_.isObject(v)) { + obj[k] = _.encodeDates(v); // recurse + } + }); + return obj; + }; + + _.timestamp = function() { + Date.now = Date.now || function() { + return +new Date; + }; + return Date.now(); + }; + + _.formatDate = function(d) { + // YYYY-MM-DDTHH:MM:SS in UTC + function pad(n) { + return n < 10 ? '0' + n : n; + } + return d.getUTCFullYear() + '-' + + pad(d.getUTCMonth() + 1) + '-' + + pad(d.getUTCDate()) + 'T' + + pad(d.getUTCHours()) + ':' + + pad(d.getUTCMinutes()) + ':' + + pad(d.getUTCSeconds()); + }; + + _.safewrap = function(f) { + return function() { + try { + return f.apply(this, arguments); + } catch (e) { + console$1.critical('Implementation error. Please turn on debug and contact support@mixpanel.com.'); + if (Config.DEBUG){ + console$1.critical(e); + } + } + }; + }; + + _.safewrap_class = function(klass, functions) { + for (var i = 0; i < functions.length; i++) { + klass.prototype[functions[i]] = _.safewrap(klass.prototype[functions[i]]); + } + }; + + _.safewrap_instance_methods = function(obj) { + for (var func in obj) { + if (typeof(obj[func]) === 'function') { + obj[func] = _.safewrap(obj[func]); + } + } + }; + + _.strip_empty_properties = function(p) { + var ret = {}; + _.each(p, function(v, k) { + if (_.isString(v) && v.length > 0) { + ret[k] = v; + } + }); + return ret; + }; + + /* + * this function returns a copy of object after truncating it. If + * passed an Array or Object it will iterate through obj and + * truncate all the values recursively. + */ + _.truncate = function(obj, length) { + var ret; + + if (typeof(obj) === 'string') { + ret = obj.slice(0, length); + } else if (_.isArray(obj)) { + ret = []; + _.each(obj, function(val) { + ret.push(_.truncate(val, length)); + }); + } else if (_.isObject(obj)) { + ret = {}; + _.each(obj, function(val, key) { + ret[key] = _.truncate(val, length); + }); + } else { + ret = obj; + } + + return ret; + }; + + _.JSONEncode = (function() { + return function(mixed_val) { + var value = mixed_val; + var quote = function(string) { + var escapable = /[\\\"\x00-\x1f\x7f-\x9f\u00ad\u0600-\u0604\u070f\u17b4\u17b5\u200c-\u200f\u2028-\u202f\u2060-\u206f\ufeff\ufff0-\uffff]/g; // eslint-disable-line no-control-regex + var meta = { // table of character substitutions + '\b': '\\b', + '\t': '\\t', + '\n': '\\n', + '\f': '\\f', + '\r': '\\r', + '"': '\\"', + '\\': '\\\\' + }; + + escapable.lastIndex = 0; + return escapable.test(string) ? + '"' + string.replace(escapable, function(a) { + var c = meta[a]; + return typeof c === 'string' ? c : + '\\u' + ('0000' + a.charCodeAt(0).toString(16)).slice(-4); + }) + '"' : + '"' + string + '"'; + }; + + var str = function(key, holder) { + var gap = ''; + var indent = ' '; + var i = 0; // The loop counter. + var k = ''; // The member key. + var v = ''; // The member value. + var length = 0; + var mind = gap; + var partial = []; + var value = holder[key]; + + // If the value has a toJSON method, call it to obtain a replacement value. + if (value && typeof value === 'object' && + typeof value.toJSON === 'function') { + value = value.toJSON(key); + } + + // What happens next depends on the value's type. + switch (typeof value) { + case 'string': + return quote(value); + + case 'number': + // JSON numbers must be finite. Encode non-finite numbers as null. + return isFinite(value) ? String(value) : 'null'; + + case 'boolean': + case 'null': + // If the value is a boolean or null, convert it to a string. Note: + // typeof null does not produce 'null'. The case is included here in + // the remote chance that this gets fixed someday. + + return String(value); + + case 'object': + // If the type is 'object', we might be dealing with an object or an array or + // null. + // Due to a specification blunder in ECMAScript, typeof null is 'object', + // so watch out for that case. + if (!value) { + return 'null'; + } + + // Make an array to hold the partial results of stringifying this object value. + gap += indent; + partial = []; + + // Is the value an array? + if (toString.apply(value) === '[object Array]') { + // The value is an array. Stringify every element. Use null as a placeholder + // for non-JSON values. + + length = value.length; + for (i = 0; i < length; i += 1) { + partial[i] = str(i, value) || 'null'; + } + + // Join all of the elements together, separated with commas, and wrap them in + // brackets. + v = partial.length === 0 ? '[]' : + gap ? '[\n' + gap + + partial.join(',\n' + gap) + '\n' + + mind + ']' : + '[' + partial.join(',') + ']'; + gap = mind; + return v; + } + + // Iterate through all of the keys in the object. + for (k in value) { + if (hasOwnProperty.call(value, k)) { + v = str(k, value); + if (v) { + partial.push(quote(k) + (gap ? ': ' : ':') + v); + } + } + } + + // Join all of the member texts together, separated with commas, + // and wrap them in braces. + v = partial.length === 0 ? '{}' : + gap ? '{' + partial.join(',') + '' + + mind + '}' : '{' + partial.join(',') + '}'; + gap = mind; + return v; + } + }; + + // Make a fake root object containing our value under the key of ''. + // Return the result of stringifying the value. + return str('', { + '': value + }); + }; + })(); + + /** + * From https://github.com/douglascrockford/JSON-js/blob/master/json_parse.js + * Slightly modified to throw a real Error rather than a POJO + */ + _.JSONDecode = (function() { + var at, // The index of the current character + ch, // The current character + escapee = { + '"': '"', + '\\': '\\', + '/': '/', + 'b': '\b', + 'f': '\f', + 'n': '\n', + 'r': '\r', + 't': '\t' + }, + text, + error = function(m) { + var e = new SyntaxError(m); + e.at = at; + e.text = text; + throw e; + }, + next = function(c) { + // If a c parameter is provided, verify that it matches the current character. + if (c && c !== ch) { + error('Expected \'' + c + '\' instead of \'' + ch + '\''); + } + // Get the next character. When there are no more characters, + // return the empty string. + ch = text.charAt(at); + at += 1; + return ch; + }, + number = function() { + // Parse a number value. + var number, + string = ''; + + if (ch === '-') { + string = '-'; + next('-'); + } + while (ch >= '0' && ch <= '9') { + string += ch; + next(); + } + if (ch === '.') { + string += '.'; + while (next() && ch >= '0' && ch <= '9') { + string += ch; + } + } + if (ch === 'e' || ch === 'E') { + string += ch; + next(); + if (ch === '-' || ch === '+') { + string += ch; + next(); + } + while (ch >= '0' && ch <= '9') { + string += ch; + next(); + } + } + number = +string; + if (!isFinite(number)) { + error('Bad number'); + } else { + return number; + } + }, + + string = function() { + // Parse a string value. + var hex, + i, + string = '', + uffff; + // When parsing for string values, we must look for " and \ characters. + if (ch === '"') { + while (next()) { + if (ch === '"') { + next(); + return string; + } + if (ch === '\\') { + next(); + if (ch === 'u') { + uffff = 0; + for (i = 0; i < 4; i += 1) { + hex = parseInt(next(), 16); + if (!isFinite(hex)) { + break; + } + uffff = uffff * 16 + hex; + } + string += String.fromCharCode(uffff); + } else if (typeof escapee[ch] === 'string') { + string += escapee[ch]; + } else { + break; + } + } else { + string += ch; + } + } + } + error('Bad string'); + }, + white = function() { + // Skip whitespace. + while (ch && ch <= ' ') { + next(); + } + }, + word = function() { + // true, false, or null. + switch (ch) { + case 't': + next('t'); + next('r'); + next('u'); + next('e'); + return true; + case 'f': + next('f'); + next('a'); + next('l'); + next('s'); + next('e'); + return false; + case 'n': + next('n'); + next('u'); + next('l'); + next('l'); + return null; + } + error('Unexpected "' + ch + '"'); + }, + value, // Placeholder for the value function. + array = function() { + // Parse an array value. + var array = []; + + if (ch === '[') { + next('['); + white(); + if (ch === ']') { + next(']'); + return array; // empty array + } + while (ch) { + array.push(value()); + white(); + if (ch === ']') { + next(']'); + return array; + } + next(','); + white(); + } + } + error('Bad array'); + }, + object = function() { + // Parse an object value. + var key, + object = {}; + + if (ch === '{') { + next('{'); + white(); + if (ch === '}') { + next('}'); + return object; // empty object + } + while (ch) { + key = string(); + white(); + next(':'); + if (Object.hasOwnProperty.call(object, key)) { + error('Duplicate key "' + key + '"'); + } + object[key] = value(); + white(); + if (ch === '}') { + next('}'); + return object; + } + next(','); + white(); + } + } + error('Bad object'); + }; + + value = function() { + // Parse a JSON value. It could be an object, an array, a string, + // a number, or a word. + white(); + switch (ch) { + case '{': + return object(); + case '[': + return array(); + case '"': + return string(); + case '-': + return number(); + default: + return ch >= '0' && ch <= '9' ? number() : word(); + } + }; + + // Return the json_parse function. It will have access to all of the + // above functions and variables. + return function(source) { + var result; + + text = source; + at = 0; + ch = ' '; + result = value(); + white(); + if (ch) { + error('Syntax error'); + } + + return result; + }; + })(); + + _.base64Encode = function(data) { + var b64 = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/='; + var o1, o2, o3, h1, h2, h3, h4, bits, i = 0, + ac = 0, + enc = '', + tmp_arr = []; + + if (!data) { + return data; + } + + data = _.utf8Encode(data); + + do { // pack three octets into four hexets + o1 = data.charCodeAt(i++); + o2 = data.charCodeAt(i++); + o3 = data.charCodeAt(i++); + + bits = o1 << 16 | o2 << 8 | o3; + + h1 = bits >> 18 & 0x3f; + h2 = bits >> 12 & 0x3f; + h3 = bits >> 6 & 0x3f; + h4 = bits & 0x3f; + + // use hexets to index into b64, and append result to encoded string + tmp_arr[ac++] = b64.charAt(h1) + b64.charAt(h2) + b64.charAt(h3) + b64.charAt(h4); + } while (i < data.length); + + enc = tmp_arr.join(''); + + switch (data.length % 3) { + case 1: + enc = enc.slice(0, -2) + '=='; + break; + case 2: + enc = enc.slice(0, -1) + '='; + break; + } + + return enc; + }; + + _.utf8Encode = function(string) { + string = (string + '').replace(/\r\n/g, '\n').replace(/\r/g, '\n'); + + var utftext = '', + start, + end; + var stringl = 0, + n; + + start = end = 0; + stringl = string.length; + + for (n = 0; n < stringl; n++) { + var c1 = string.charCodeAt(n); + var enc = null; + + if (c1 < 128) { + end++; + } else if ((c1 > 127) && (c1 < 2048)) { + enc = String.fromCharCode((c1 >> 6) | 192, (c1 & 63) | 128); + } else { + enc = String.fromCharCode((c1 >> 12) | 224, ((c1 >> 6) & 63) | 128, (c1 & 63) | 128); + } + if (enc !== null) { + if (end > start) { + utftext += string.substring(start, end); + } + utftext += enc; + start = end = n + 1; + } + } + + if (end > start) { + utftext += string.substring(start, string.length); + } + + return utftext; + }; + + _.UUID = (function() { + + // Time/ticks information + // 1*new Date() is a cross browser version of Date.now() + var T = function() { + var d = 1 * new Date(), + i = 0; + + // this while loop figures how many browser ticks go by + // before 1*new Date() returns a new number, ie the amount + // of ticks that go by per millisecond + while (d == 1 * new Date()) { + i++; + } + + return d.toString(16) + i.toString(16); + }; + + // Math.Random entropy + var R = function() { + return Math.random().toString(16).replace('.', ''); + }; + + // User agent entropy + // This function takes the user agent string, and then xors + // together each sequence of 8 bytes. This produces a final + // sequence of 8 bytes which it returns as hex. + var UA = function() { + var ua = userAgent, + i, ch, buffer = [], + ret = 0; + + function xor(result, byte_array) { + var j, tmp = 0; + for (j = 0; j < byte_array.length; j++) { + tmp |= (buffer[j] << j * 8); + } + return result ^ tmp; + } + + for (i = 0; i < ua.length; i++) { + ch = ua.charCodeAt(i); + buffer.unshift(ch & 0xFF); + if (buffer.length >= 4) { + ret = xor(ret, buffer); + buffer = []; + } + } + + if (buffer.length > 0) { + ret = xor(ret, buffer); + } + + return ret.toString(16); + }; + + return function() { + var se = (screen.height * screen.width).toString(16); + return (T() + '-' + R() + '-' + UA() + '-' + se + '-' + T()); + }; + })(); + + // _.isBlockedUA() + // This is to block various web spiders from executing our JS and + // sending false tracking data + _.isBlockedUA = function(ua) { + if (/(google web preview|baiduspider|yandexbot|bingbot|googlebot|yahoo! slurp)/i.test(ua)) { + return true; + } + return false; + }; + + /** + * @param {Object=} formdata + * @param {string=} arg_separator + */ + _.HTTPBuildQuery = function(formdata, arg_separator) { + var use_val, use_key, tmp_arr = []; + + if (_.isUndefined(arg_separator)) { + arg_separator = '&'; + } + + _.each(formdata, function(val, key) { + use_val = encodeURIComponent(val.toString()); + use_key = encodeURIComponent(key); + tmp_arr[tmp_arr.length] = use_key + '=' + use_val; + }); + + return tmp_arr.join(arg_separator); + }; + + _.getQueryParam = function(url, param) { + // Expects a raw URL + + param = param.replace(/[\[]/, '\\\[').replace(/[\]]/, '\\\]'); + var regexS = '[\\?&]' + param + '=([^&#]*)', + regex = new RegExp(regexS), + results = regex.exec(url); + if (results === null || (results && typeof(results[1]) !== 'string' && results[1].length)) { + return ''; + } else { + return decodeURIComponent(results[1]).replace(/\+/g, ' '); + } + }; + + _.getHashParam = function(hash, param) { + var matches = hash.match(new RegExp(param + '=([^&]*)')); + return matches ? matches[1] : null; + }; + + // _.cookie + // Methods partially borrowed from quirksmode.org/js/cookies.html + _.cookie = { + get: function(name) { + var nameEQ = name + '='; + var ca = document$1.cookie.split(';'); + for (var i = 0; i < ca.length; i++) { + var c = ca[i]; + while (c.charAt(0) == ' ') { + c = c.substring(1, c.length); + } + if (c.indexOf(nameEQ) === 0) { + return decodeURIComponent(c.substring(nameEQ.length, c.length)); + } + } + return null; + }, + + parse: function(name) { + var cookie; + try { + cookie = _.JSONDecode(_.cookie.get(name)) || {}; + } catch (err) { + // noop + } + return cookie; + }, + + set_seconds: function(name, value, seconds, cross_subdomain, is_secure) { + var cdomain = '', + expires = '', + secure = ''; + + if (cross_subdomain) { + var matches = document$1.location.hostname.match(/[a-z0-9][a-z0-9\-]+\.[a-z\.]{2,6}$/i), + domain = matches ? matches[0] : ''; + + cdomain = ((domain) ? '; domain=.' + domain : ''); + } + + if (seconds) { + var date = new Date(); + date.setTime(date.getTime() + (seconds * 1000)); + expires = '; expires=' + date.toGMTString(); + } + + if (is_secure) { + secure = '; secure'; + } + + document$1.cookie = name + '=' + encodeURIComponent(value) + expires + '; path=/' + cdomain + secure; + }, + + set: function(name, value, days, cross_subdomain, is_secure) { + var cdomain = '', expires = '', secure = ''; + + if (cross_subdomain) { + var matches = document$1.location.hostname.match(/[a-z0-9][a-z0-9\-]+\.[a-z\.]{2,6}$/i), + domain = matches ? matches[0] : ''; + + cdomain = ((domain) ? '; domain=.' + domain : ''); + } + + if (days) { + var date = new Date(); + date.setTime(date.getTime() + (days * 24 * 60 * 60 * 1000)); + expires = '; expires=' + date.toGMTString(); + } + + if (is_secure) { + secure = '; secure'; + } + + var new_cookie_val = name + '=' + encodeURIComponent(value) + expires + '; path=/' + cdomain + secure; + document$1.cookie = new_cookie_val; + return new_cookie_val; + }, + + remove: function(name, cross_subdomain) { + _.cookie.set(name, '', -1, cross_subdomain); + } + }; + + // _.localStorage + _.localStorage = { + error: function(msg) { + console$1.error('localStorage error: ' + msg); + }, + + get: function(name) { + try { + return window.localStorage.getItem(name); + } catch (err) { + _.localStorage.error(err); + } + return null; + }, + + parse: function(name) { + try { + return _.JSONDecode(_.localStorage.get(name)) || {}; + } catch (err) { + // noop + } + return null; + }, + + set: function(name, value) { + try { + window.localStorage.setItem(name, value); + } catch (err) { + _.localStorage.error(err); + } + }, + + remove: function(name) { + try { + window.localStorage.removeItem(name); + } catch (err) { + _.localStorage.error(err); + } + } + }; + + _.register_event = (function() { + // written by Dean Edwards, 2005 + // with input from Tino Zijdel - crisp@xs4all.nl + // with input from Carl Sverre - mail@carlsverre.com + // with input from Mixpanel + // http://dean.edwards.name/weblog/2005/10/add-event/ + // https://gist.github.com/1930440 + + /** + * @param {Object} element + * @param {string} type + * @param {function(...[*])} handler + * @param {boolean=} oldSchool + * @param {boolean=} useCapture + */ + var register_event = function(element, type, handler, oldSchool, useCapture) { + if (!element) { + console$1.error('No valid element provided to register_event'); + return; + } + + if (element.addEventListener && !oldSchool) { + element.addEventListener(type, handler, !!useCapture); + } else { + var ontype = 'on' + type; + var old_handler = element[ontype]; // can be undefined + element[ontype] = makeHandler(element, handler, old_handler); + } + }; + + function makeHandler(element, new_handler, old_handlers) { + var handler = function(event) { + event = event || fixEvent(window.event); + + // this basically happens in firefox whenever another script + // overwrites the onload callback and doesn't pass the event + // object to previously defined callbacks. All the browsers + // that don't define window.event implement addEventListener + // so the dom_loaded handler will still be fired as usual. + if (!event) { + return undefined; + } + + var ret = true; + var old_result, new_result; + + if (_.isFunction(old_handlers)) { + old_result = old_handlers(event); + } + new_result = new_handler.call(element, event); + + if ((false === old_result) || (false === new_result)) { + ret = false; + } + + return ret; + }; + + return handler; + } + + function fixEvent(event) { + if (event) { + event.preventDefault = fixEvent.preventDefault; + event.stopPropagation = fixEvent.stopPropagation; + } + return event; + } + fixEvent.preventDefault = function() { + this.returnValue = false; + }; + fixEvent.stopPropagation = function() { + this.cancelBubble = true; + }; + + return register_event; + })(); + + _.dom_query = (function() { + /* document.getElementsBySelector(selector) + - returns an array of element objects from the current document + matching the CSS selector. Selectors can contain element names, + class names and ids and can be nested. For example: + + elements = document.getElementsBySelector('div#main p a.external') + + Will return an array of all 'a' elements with 'external' in their + class attribute that are contained inside 'p' elements that are + contained inside the 'div' element which has id="main" + + New in version 0.4: Support for CSS2 and CSS3 attribute selectors: + See http://www.w3.org/TR/css3-selectors/#attribute-selectors + + Version 0.4 - Simon Willison, March 25th 2003 + -- Works in Phoenix 0.5, Mozilla 1.3, Opera 7, Internet Explorer 6, Internet Explorer 5 on Windows + -- Opera 7 fails + + Version 0.5 - Carl Sverre, Jan 7th 2013 + -- Now uses jQuery-esque `hasClass` for testing class name + equality. This fixes a bug related to '-' characters being + considered not part of a 'word' in regex. + */ + + function getAllChildren(e) { + // Returns all children of element. Workaround required for IE5/Windows. Ugh. + return e.all ? e.all : e.getElementsByTagName('*'); + } + + var bad_whitespace = /[\t\r\n]/g; + + function hasClass(elem, selector) { + var className = ' ' + selector + ' '; + return ((' ' + elem.className + ' ').replace(bad_whitespace, ' ').indexOf(className) >= 0); + } + + function getElementsBySelector(selector) { + // Attempt to fail gracefully in lesser browsers + if (!document$1.getElementsByTagName) { + return []; + } + // Split selector in to tokens + var tokens = selector.split(' '); + var token, bits, tagName, found, foundCount, i, j, k, elements, currentContextIndex; + var currentContext = [document$1]; + for (i = 0; i < tokens.length; i++) { + token = tokens[i].replace(/^\s+/, '').replace(/\s+$/, ''); + if (token.indexOf('#') > -1) { + // Token is an ID selector + bits = token.split('#'); + tagName = bits[0]; + var id = bits[1]; + var element = document$1.getElementById(id); + if (!element || (tagName && element.nodeName.toLowerCase() != tagName)) { + // element not found or tag with that ID not found, return false + return []; + } + // Set currentContext to contain just this element + currentContext = [element]; + continue; // Skip to next token + } + if (token.indexOf('.') > -1) { + // Token contains a class selector + bits = token.split('.'); + tagName = bits[0]; + var className = bits[1]; + if (!tagName) { + tagName = '*'; + } + // Get elements matching tag, filter them for class selector + found = []; + foundCount = 0; + for (j = 0; j < currentContext.length; j++) { + if (tagName == '*') { + elements = getAllChildren(currentContext[j]); + } else { + elements = currentContext[j].getElementsByTagName(tagName); + } + for (k = 0; k < elements.length; k++) { + found[foundCount++] = elements[k]; + } + } + currentContext = []; + currentContextIndex = 0; + for (j = 0; j < found.length; j++) { + if (found[j].className && + _.isString(found[j].className) && // some SVG elements have classNames which are not strings + hasClass(found[j], className) + ) { + currentContext[currentContextIndex++] = found[j]; + } + } + continue; // Skip to next token + } + // Code to deal with attribute selectors + var token_match = token.match(/^(\w*)\[(\w+)([=~\|\^\$\*]?)=?"?([^\]"]*)"?\]$/); + if (token_match) { + tagName = token_match[1]; + var attrName = token_match[2]; + var attrOperator = token_match[3]; + var attrValue = token_match[4]; + if (!tagName) { + tagName = '*'; + } + // Grab all of the tagName elements within current context + found = []; + foundCount = 0; + for (j = 0; j < currentContext.length; j++) { + if (tagName == '*') { + elements = getAllChildren(currentContext[j]); + } else { + elements = currentContext[j].getElementsByTagName(tagName); + } + for (k = 0; k < elements.length; k++) { + found[foundCount++] = elements[k]; + } + } + currentContext = []; + currentContextIndex = 0; + var checkFunction; // This function will be used to filter the elements + switch (attrOperator) { + case '=': // Equality + checkFunction = function(e) { + return (e.getAttribute(attrName) == attrValue); + }; + break; + case '~': // Match one of space seperated words + checkFunction = function(e) { + return (e.getAttribute(attrName).match(new RegExp('\\b' + attrValue + '\\b'))); + }; + break; + case '|': // Match start with value followed by optional hyphen + checkFunction = function(e) { + return (e.getAttribute(attrName).match(new RegExp('^' + attrValue + '-?'))); + }; + break; + case '^': // Match starts with value + checkFunction = function(e) { + return (e.getAttribute(attrName).indexOf(attrValue) === 0); + }; + break; + case '$': // Match ends with value - fails with "Warning" in Opera 7 + checkFunction = function(e) { + return (e.getAttribute(attrName).lastIndexOf(attrValue) == e.getAttribute(attrName).length - attrValue.length); + }; + break; + case '*': // Match ends with value + checkFunction = function(e) { + return (e.getAttribute(attrName).indexOf(attrValue) > -1); + }; + break; + default: + // Just test for existence of attribute + checkFunction = function(e) { + return e.getAttribute(attrName); + }; + } + currentContext = []; + currentContextIndex = 0; + for (j = 0; j < found.length; j++) { + if (checkFunction(found[j])) { + currentContext[currentContextIndex++] = found[j]; + } + } + // alert('Attribute Selector: '+tagName+' '+attrName+' '+attrOperator+' '+attrValue); + continue; // Skip to next token + } + // If we get here, token is JUST an element (not a class or ID selector) + tagName = token; + found = []; + foundCount = 0; + for (j = 0; j < currentContext.length; j++) { + elements = currentContext[j].getElementsByTagName(tagName); + for (k = 0; k < elements.length; k++) { + found[foundCount++] = elements[k]; + } + } + currentContext = found; + } + return currentContext; + } + + return function(query) { + if (_.isElement(query)) { + return [query]; + } else if (_.isObject(query) && !_.isUndefined(query.length)) { + return query; + } else { + return getElementsBySelector.call(this, query); + } + }; + })(); + + _.info = { + campaignParams: function() { + var campaign_keywords = 'utm_source utm_medium utm_campaign utm_content utm_term'.split(' '), + kw = '', + params = {}; + _.each(campaign_keywords, function(kwkey) { + kw = _.getQueryParam(document$1.URL, kwkey); + if (kw.length) { + params[kwkey] = kw; + } + }); + + return params; + }, + + searchEngine: function(referrer) { + if (referrer.search('https?://(.*)google.([^/?]*)') === 0) { + return 'google'; + } else if (referrer.search('https?://(.*)bing.com') === 0) { + return 'bing'; + } else if (referrer.search('https?://(.*)yahoo.com') === 0) { + return 'yahoo'; + } else if (referrer.search('https?://(.*)duckduckgo.com') === 0) { + return 'duckduckgo'; + } else { + return null; + } + }, + + searchInfo: function(referrer) { + var search = _.info.searchEngine(referrer), + param = (search != 'yahoo') ? 'q' : 'p', + ret = {}; + + if (search !== null) { + ret['$search_engine'] = search; + + var keyword = _.getQueryParam(referrer, param); + if (keyword.length) { + ret['mp_keyword'] = keyword; + } + } + + return ret; + }, + + /** + * This function detects which browser is running this script. + * The order of the checks are important since many user agents + * include key words used in later checks. + */ + browser: function(user_agent, vendor, opera) { + vendor = vendor || ''; // vendor is undefined for at least IE9 + if (opera || _.includes(user_agent, ' OPR/')) { + if (_.includes(user_agent, 'Mini')) { + return 'Opera Mini'; + } + return 'Opera'; + } else if (/(BlackBerry|PlayBook|BB10)/i.test(user_agent)) { + return 'BlackBerry'; + } else if (_.includes(user_agent, 'IEMobile') || _.includes(user_agent, 'WPDesktop')) { + return 'Internet Explorer Mobile'; + } else if (_.includes(user_agent, 'Edge')) { + return 'Microsoft Edge'; + } else if (_.includes(user_agent, 'FBIOS')) { + return 'Facebook Mobile'; + } else if (_.includes(user_agent, 'Chrome')) { + return 'Chrome'; + } else if (_.includes(user_agent, 'CriOS')) { + return 'Chrome iOS'; + } else if (_.includes(user_agent, 'UCWEB') || _.includes(user_agent, 'UCBrowser')) { + return 'UC Browser'; + } else if (_.includes(user_agent, 'FxiOS')) { + return 'Firefox iOS'; + } else if (_.includes(vendor, 'Apple')) { + if (_.includes(user_agent, 'Mobile')) { + return 'Mobile Safari'; + } + return 'Safari'; + } else if (_.includes(user_agent, 'Android')) { + return 'Android Mobile'; + } else if (_.includes(user_agent, 'Konqueror')) { + return 'Konqueror'; + } else if (_.includes(user_agent, 'Firefox')) { + return 'Firefox'; + } else if (_.includes(user_agent, 'MSIE') || _.includes(user_agent, 'Trident/')) { + return 'Internet Explorer'; + } else if (_.includes(user_agent, 'Gecko')) { + return 'Mozilla'; + } else { + return ''; + } + }, + + /** + * This function detects which browser version is running this script, + * parsing major and minor version (e.g., 42.1). User agent strings from: + * http://www.useragentstring.com/pages/useragentstring.php + */ + browserVersion: function(userAgent, vendor, opera) { + var browser = _.info.browser(userAgent, vendor, opera); + var versionRegexs = { + 'Internet Explorer Mobile': /rv:(\d+(\.\d+)?)/, + 'Microsoft Edge': /Edge\/(\d+(\.\d+)?)/, + 'Chrome': /Chrome\/(\d+(\.\d+)?)/, + 'Chrome iOS': /CriOS\/(\d+(\.\d+)?)/, + 'UC Browser' : /(UCBrowser|UCWEB)\/(\d+(\.\d+)?)/, + 'Safari': /Version\/(\d+(\.\d+)?)/, + 'Mobile Safari': /Version\/(\d+(\.\d+)?)/, + 'Opera': /(Opera|OPR)\/(\d+(\.\d+)?)/, + 'Firefox': /Firefox\/(\d+(\.\d+)?)/, + 'Firefox iOS': /FxiOS\/(\d+(\.\d+)?)/, + 'Konqueror': /Konqueror:(\d+(\.\d+)?)/, + 'BlackBerry': /BlackBerry (\d+(\.\d+)?)/, + 'Android Mobile': /android\s(\d+(\.\d+)?)/, + 'Internet Explorer': /(rv:|MSIE )(\d+(\.\d+)?)/, + 'Mozilla': /rv:(\d+(\.\d+)?)/ + }; + var regex = versionRegexs[browser]; + if (regex === undefined) { + return null; + } + var matches = userAgent.match(regex); + if (!matches) { + return null; + } + return parseFloat(matches[matches.length - 2]); + }, + + os: function() { + var a = userAgent; + if (/Windows/i.test(a)) { + if (/Phone/.test(a) || /WPDesktop/.test(a)) { + return 'Windows Phone'; + } + return 'Windows'; + } else if (/(iPhone|iPad|iPod)/.test(a)) { + return 'iOS'; + } else if (/Android/.test(a)) { + return 'Android'; + } else if (/(BlackBerry|PlayBook|BB10)/i.test(a)) { + return 'BlackBerry'; + } else if (/Mac/i.test(a)) { + return 'Mac OS X'; + } else if (/Linux/.test(a)) { + return 'Linux'; + } else if (/CrOS/.test(a)) { + return 'Chrome OS'; + } else { + return ''; + } + }, + + device: function(user_agent) { + if (/Windows Phone/i.test(user_agent) || /WPDesktop/.test(user_agent)) { + return 'Windows Phone'; + } else if (/iPad/.test(user_agent)) { + return 'iPad'; + } else if (/iPod/.test(user_agent)) { + return 'iPod Touch'; + } else if (/iPhone/.test(user_agent)) { + return 'iPhone'; + } else if (/(BlackBerry|PlayBook|BB10)/i.test(user_agent)) { + return 'BlackBerry'; + } else if (/Android/.test(user_agent)) { + return 'Android'; + } else { + return ''; + } + }, + + referringDomain: function(referrer) { + var split = referrer.split('/'); + if (split.length >= 3) { + return split[2]; + } + return ''; + }, + + properties: function() { + return _.extend(_.strip_empty_properties({ + '$os': _.info.os(), + '$browser': _.info.browser(userAgent, navigator$1.vendor, windowOpera), + '$referrer': document$1.referrer, + '$referring_domain': _.info.referringDomain(document$1.referrer), + '$device': _.info.device(userAgent) + }), { + '$current_url': win.location.href, + '$browser_version': _.info.browserVersion(userAgent, navigator$1.vendor, windowOpera), + '$screen_height': screen.height, + '$screen_width': screen.width, + 'mp_lib': 'web', + '$lib_version': Config.LIB_VERSION + }); + }, + + people_properties: function() { + return _.extend(_.strip_empty_properties({ + '$os': _.info.os(), + '$browser': _.info.browser(userAgent, navigator$1.vendor, windowOpera) + }), { + '$browser_version': _.info.browserVersion(userAgent, navigator$1.vendor, windowOpera) + }); + }, + + pageviewInfo: function(page) { + return _.strip_empty_properties({ + 'mp_page': page, + 'mp_referrer': document$1.referrer, + 'mp_browser': _.info.browser(userAgent, navigator$1.vendor, windowOpera), + 'mp_platform': _.info.os() + }); + } + }; + + // EXPORTS (for closure compiler) + _['toArray'] = _.toArray; + _['isObject'] = _.isObject; + _['JSONEncode'] = _.JSONEncode; + _['JSONDecode'] = _.JSONDecode; + _['isBlockedUA'] = _.isBlockedUA; + _['isEmptyObject'] = _.isEmptyObject; + _['info'] = _.info; + _['info']['device'] = _.info.device; + _['info']['browser'] = _.info.browser; + _['info']['properties'] = _.info.properties; + + /* + * Get the className of an element, accounting for edge cases where element.className is an object + * @param {Element} el - element to get the className of + * @returns {string} the element's class + */ + function getClassName(el) { + switch(typeof el.className) { + case 'string': + return el.className; + case 'object': // handle cases where className might be SVGAnimatedString or some other type + return el.className.baseVal || el.getAttribute('class') || ''; + default: // future proof + return ''; + } + } + + /* + * Get the direct text content of an element, protecting against sensitive data collection. + * Concats textContent of each of the element's text node children; this avoids potential + * collection of sensitive data that could happen if we used element.textContent and the + * element had sensitive child elements, since element.textContent includes child content. + * Scrubs values that look like they could be sensitive (i.e. cc or ssn number). + * @param {Element} el - element to get the text of + * @returns {string} the element's direct text content + */ + function getSafeText(el) { + var elText = ''; + + if (shouldTrackElement(el) && el.childNodes && el.childNodes.length) { + _.each(el.childNodes, function(child) { + if (isTextNode(child) && child.textContent) { + elText += _.trim(child.textContent) + // scrub potentially sensitive values + .split(/(\s+)/).filter(shouldTrackValue).join('') + // normalize whitespace + .replace(/[\r\n]/g, ' ').replace(/[ ]+/g, ' ') + // truncate + .substring(0, 255); + } + }); + } + + return _.trim(elText); + } + + /* + * Check whether an element has nodeType Node.ELEMENT_NODE + * @param {Element} el - element to check + * @returns {boolean} whether el is of the correct nodeType + */ + function isElementNode(el) { + return el && el.nodeType === 1; // Node.ELEMENT_NODE - use integer constant for browser portability + } + + /* + * Check whether an element is of a given tag type. + * Due to potential reference discrepancies (such as the webcomponents.js polyfill), + * we want to match tagNames instead of specific references because something like + * element === document.body won't always work because element might not be a native + * element. + * @param {Element} el - element to check + * @param {string} tag - tag name (e.g., "div") + * @returns {boolean} whether el is of the given tag type + */ + function isTag(el, tag) { + return el && el.tagName && el.tagName.toLowerCase() === tag.toLowerCase(); + } + + /* + * Check whether an element has nodeType Node.TEXT_NODE + * @param {Element} el - element to check + * @returns {boolean} whether el is of the correct nodeType + */ + function isTextNode(el) { + return el && el.nodeType === 3; // Node.TEXT_NODE - use integer constant for browser portability + } + + /* + * Check whether a DOM event should be "tracked" or if it may contain sentitive data + * using a variety of heuristics. + * @param {Element} el - element to check + * @param {Event} event - event to check + * @returns {boolean} whether the event should be tracked + */ + function shouldTrackDomEvent(el, event) { + if (!el || isTag(el, 'html') || !isElementNode(el)) { + return false; + } + var tag = el.tagName.toLowerCase(); + switch (tag) { + case 'html': + return false; + case 'form': + return event.type === 'submit'; + case 'input': + if (['button', 'submit'].indexOf(el.getAttribute('type')) === -1) { + return event.type === 'change'; + } else { + return event.type === 'click'; + } + case 'select': + case 'textarea': + return event.type === 'change'; + default: + return event.type === 'click'; + } + } + + /* + * Check whether a DOM element should be "tracked" or if it may contain sentitive data + * using a variety of heuristics. + * @param {Element} el - element to check + * @returns {boolean} whether the element should be tracked + */ + function shouldTrackElement(el) { + for (var curEl = el; curEl.parentNode && !isTag(curEl, 'body'); curEl = curEl.parentNode) { + var classes = getClassName(curEl).split(' '); + if (_.includes(classes, 'mp-sensitive') || _.includes(classes, 'mp-no-track')) { + return false; + } + } + + if (_.includes(getClassName(el).split(' '), 'mp-include')) { + return true; + } + + // don't send data from inputs or similar elements since there will always be + // a risk of clientside javascript placing sensitive data in attributes + if ( + isTag(el, 'input') || + isTag(el, 'select') || + isTag(el, 'textarea') || + el.getAttribute('contenteditable') === 'true' + ) { + return false; + } + + // don't include hidden or password fields + var type = el.type || ''; + if (typeof type === 'string') { // it's possible for el.type to be a DOM element if el is a form with a child input[name="type"] + switch(type.toLowerCase()) { + case 'hidden': + return false; + case 'password': + return false; + } + } + + // filter out data from fields that look like sensitive fields + var name = el.name || el.id || ''; + if (typeof name === 'string') { // it's possible for el.name or el.id to be a DOM element if el is a form with a child input[name="name"] + var sensitiveNameRegex = /^cc|cardnum|ccnum|creditcard|csc|cvc|cvv|exp|pass|pwd|routing|seccode|securitycode|securitynum|socialsec|socsec|ssn/i; + if (sensitiveNameRegex.test(name.replace(/[^a-zA-Z0-9]/g, ''))) { + return false; + } + } + + return true; + } + + /* + * Check whether a string value should be "tracked" or if it may contain sentitive data + * using a variety of heuristics. + * @param {string} value - string value to check + * @returns {boolean} whether the element should be tracked + */ + function shouldTrackValue(value) { + if (value === null || _.isUndefined(value)) { + return false; + } + + if (typeof value === 'string') { + value = _.trim(value); + + // check to see if input value looks like a credit card number + // see: https://www.safaribooksonline.com/library/view/regular-expressions-cookbook/9781449327453/ch04s20.html + var ccRegex = /^(?:(4[0-9]{12}(?:[0-9]{3})?)|(5[1-5][0-9]{14})|(6(?:011|5[0-9]{2})[0-9]{12})|(3[47][0-9]{13})|(3(?:0[0-5]|[68][0-9])[0-9]{11})|((?:2131|1800|35[0-9]{3})[0-9]{11}))$/; + if (ccRegex.test((value || '').replace(/[\- ]/g, ''))) { + return false; + } + + // check to see if input value looks like a social security number + var ssnRegex = /(^\d{3}-?\d{2}-?\d{4}$)/; + if (ssnRegex.test(value)) { + return false; + } + } + + return true; + } + + var autotrack = { + _initializedTokens: [], + + _previousElementSibling: function(el) { + if (el.previousElementSibling) { + return el.previousElementSibling; + } else { + do { + el = el.previousSibling; + } while (el && !isElementNode(el)); + return el; + } + }, + + _loadScript: function(scriptUrlToLoad, callback) { + var scriptTag = document.createElement('script'); + scriptTag.type = 'text/javascript'; + scriptTag.src = scriptUrlToLoad; + scriptTag.onload = callback; + + var scripts = document.getElementsByTagName('script'); + if (scripts.length > 0) { + scripts[0].parentNode.insertBefore(scriptTag, scripts[0]); + } else { + document.body.appendChild(scriptTag); + } + }, + + _getPropertiesFromElement: function(elem) { + var props = { + 'classes': getClassName(elem).split(' '), + 'tag_name': elem.tagName.toLowerCase() + }; + + if (shouldTrackElement(elem)) { + _.each(elem.attributes, function(attr) { + if (shouldTrackValue(attr.value)) { + props['attr__' + attr.name] = attr.value; + } + }); + } + + var nthChild = 1; + var nthOfType = 1; + var currentElem = elem; + while (currentElem = this._previousElementSibling(currentElem)) { // eslint-disable-line no-cond-assign + nthChild++; + if (currentElem.tagName === elem.tagName) { + nthOfType++; + } + } + props['nth_child'] = nthChild; + props['nth_of_type'] = nthOfType; + + return props; + }, + + _getDefaultProperties: function(eventType) { + return { + '$event_type': eventType, + '$ce_version': 1, + '$host': window.location.host, + '$pathname': window.location.pathname + }; + }, + + _extractCustomPropertyValue: function(customProperty) { + var propValues = []; + _.each(document.querySelectorAll(customProperty['css_selector']), function(matchedElem) { + var value; + + if (['input', 'select'].indexOf(matchedElem.tagName.toLowerCase()) > -1) { + value = matchedElem['value']; + } else if (matchedElem['textContent']) { + value = matchedElem['textContent']; + } + + if (shouldTrackValue(value)) { + propValues.push(value); + } + }); + return propValues.join(', '); + }, + + _getCustomProperties: function(targetElementList) { + var props = {}; + _.each(this._customProperties, function(customProperty) { + _.each(customProperty['event_selectors'], function(eventSelector) { + var eventElements = document.querySelectorAll(eventSelector); + _.each(eventElements, function(eventElement) { + if (_.includes(targetElementList, eventElement) && shouldTrackElement(eventElement)) { + props[customProperty['name']] = this._extractCustomPropertyValue(customProperty); + } + }, this); + }, this); + }, this); + return props; + }, + + _getEventTarget: function(e) { + // https://developer.mozilla.org/en-US/docs/Web/API/Event/target#Compatibility_notes + if (typeof e.target === 'undefined') { + return e.srcElement; + } else { + return e.target; + } + }, + + _trackEvent: function(e, instance) { + /*** Don't mess with this code without running IE8 tests on it ***/ + var target = this._getEventTarget(e); + if (isTextNode(target)) { // defeat Safari bug (see: http://www.quirksmode.org/js/events_properties.html) + target = target.parentNode; + } + + if (shouldTrackDomEvent(target, e)) { + var targetElementList = [target]; + var curEl = target; + while (curEl.parentNode && !isTag(curEl, 'body')) { + targetElementList.push(curEl.parentNode); + curEl = curEl.parentNode; + } + + var elementsJson = []; + var href, explicitNoTrack = false; + _.each(targetElementList, function(el) { + var shouldTrackEl = shouldTrackElement(el); + + // if the element or a parent element is an anchor tag + // include the href as a property + if (el.tagName.toLowerCase() === 'a') { + href = el.getAttribute('href'); + href = shouldTrackEl && shouldTrackValue(href) && href; + } + + // allow users to programatically prevent tracking of elements by adding class 'mp-no-track' + var classes = getClassName(el).split(' '); + if (_.includes(classes, 'mp-no-track')) { + explicitNoTrack = true; + } + + elementsJson.push(this._getPropertiesFromElement(el)); + }, this); + + if (explicitNoTrack) { + return false; + } + + // only populate text content from target element (not parents) + // to prevent text within a sensitive element from being collected + // as part of a parent's el.textContent + var elementText; + var safeElementText = getSafeText(target); + if (safeElementText && safeElementText.length) { + elementText = safeElementText; + } + + var props = _.extend( + this._getDefaultProperties(e.type), + { + '$elements': elementsJson, + '$el_attr__href': href, + '$el_text': elementText + }, + this._getCustomProperties(targetElementList) + ); + + instance.track('$web_event', props); + return true; + } + }, + + // only reason is to stub for unit tests + // since you can't override window.location props + _navigate: function(href) { + window.location.href = href; + }, + + _addDomEventHandlers: function(instance) { + var handler = _.bind(function(e) { + e = e || window.event; + this._trackEvent(e, instance); + }, this); + _.register_event(document, 'submit', handler, false, true); + _.register_event(document, 'change', handler, false, true); + _.register_event(document, 'click', handler, false, true); + }, + + _customProperties: {}, + init: function(instance) { + if (!(document && document.body)) { + console.log('document not ready yet, trying again in 500 milliseconds...'); + var that = this; + setTimeout(function() { that.init(instance); }, 500); + return; + } + + var token = instance.get_config('token'); + if (this._initializedTokens.indexOf(token) > -1) { + console.log('autotrack already initialized for token "' + token + '"'); + return; + } + this._initializedTokens.push(token); + + if (!this._maybeLoadEditor(instance)) { // don't autotrack actions when the editor is enabled + var parseDecideResponse = _.bind(function(response) { + if (response && response['config'] && response['config']['enable_collect_everything'] === true) { + + if (response['custom_properties']) { + this._customProperties = response['custom_properties']; + } + + instance.track('$web_event', _.extend({ + '$title': document.title + }, this._getDefaultProperties('pageview'))); + + this._addDomEventHandlers(instance); + + } else { + instance['__autotrack_enabled'] = false; + } + }, this); + + instance._send_request( + instance.get_config('api_host') + '/decide/', { + 'verbose': true, + 'version': '1', + 'lib': 'web', + 'token': token + }, + instance._prepare_callback(parseDecideResponse) + ); + } + }, + + _editorParamsFromHash: function(instance, hash) { + var editorParams; + try { + var state = _.getHashParam(hash, 'state'); + state = JSON.parse(decodeURIComponent(state)); + var expiresInSeconds = _.getHashParam(hash, 'expires_in'); + editorParams = { + 'accessToken': _.getHashParam(hash, 'access_token'), + 'accessTokenExpiresAt': (new Date()).getTime() + (Number(expiresInSeconds) * 1000), + 'bookmarkletMode': !!state['bookmarkletMode'], + 'projectId': state['projectId'], + 'projectOwnerId': state['projectOwnerId'], + 'projectToken': state['token'], + 'readOnly': state['readOnly'], + 'userFlags': state['userFlags'], + 'userId': state['userId'] + }; + window.sessionStorage.setItem('editorParams', JSON.stringify(editorParams)); + + if (state['desiredHash']) { + window.location.hash = state['desiredHash']; + } else if (window.history) { + history.replaceState('', document.title, window.location.pathname + window.location.search); // completely remove hash + } else { + window.location.hash = ''; // clear hash (but leaves # unfortunately) + } + } catch (e) { + console.error('Unable to parse data from hash', e); + } + return editorParams; + }, + + /** + * To load the visual editor, we need an access token and other state. That state comes from one of three places: + * 1. In the URL hash params if the customer is using an old snippet + * 2. From session storage under the key `_mpcehash` if the snippet already parsed the hash + * 3. From session storage under the key `editorParams` if the editor was initialized on a previous page + */ + _maybeLoadEditor: function(instance) { + try { + var parseFromUrl = false; + if (_.getHashParam(window.location.hash, 'state')) { + var state = _.getHashParam(window.location.hash, 'state'); + state = JSON.parse(decodeURIComponent(state)); + parseFromUrl = state['action'] === 'mpeditor'; + } + var parseFromStorage = !!window.sessionStorage.getItem('_mpcehash'); + var editorParams; + + if (parseFromUrl) { // happens if they are initializing the editor using an old snippet + editorParams = this._editorParamsFromHash(instance, window.location.hash); + } else if (parseFromStorage) { // happens if they are initialized the editor and using the new snippet + editorParams = this._editorParamsFromHash(instance, window.sessionStorage.getItem('_mpcehash')); + window.sessionStorage.removeItem('_mpcehash'); + } else { // get credentials from sessionStorage from a previous initialzation + editorParams = JSON.parse(window.sessionStorage.getItem('editorParams') || '{}'); + } + + if (editorParams['projectToken'] && instance.get_config('token') === editorParams['projectToken']) { + this._loadEditor(instance, editorParams); + return true; + } else { + return false; + } + } catch (e) { + return false; + } + }, + + _loadEditor: function(instance, editorParams) { + if (!window['_mpEditorLoaded']) { // only load the codeless event editor once, even if there are multiple instances of MixpanelLib + window['_mpEditorLoaded'] = true; + var editorUrl = instance.get_config('app_host') + + '/js-bundle/reports/collect-everything/editor.js?_ts=' + + (new Date()).getTime(); + this._loadScript(editorUrl, function() { + window['mp_load_editor'](editorParams); + }); + return true; + } + return false; + }, + + // this is a mechanism to ramp up CE with no server-side interaction. + // when CE is active, every page load results in a decide request. we + // need to gently ramp this up so we don't overload decide. this decides + // deterministically if CE is enabled for this project by modding the char + // value of the project token. + enabledForProject: function(token, numBuckets, numEnabledBuckets) { + numBuckets = !_.isUndefined(numBuckets) ? numBuckets : 10; + numEnabledBuckets = !_.isUndefined(numEnabledBuckets) ? numEnabledBuckets : 10; + var charCodeSum = 0; + for (var i = 0; i < token.length; i++) { + charCodeSum += token.charCodeAt(i); + } + return (charCodeSum % numBuckets) < numEnabledBuckets; + }, + + isBrowserSupported: function() { + return _.isFunction(document.querySelectorAll); + } + }; + + _.bind_instance_methods(autotrack); + _.safewrap_instance_methods(autotrack); + + /* + * Mixpanel JS Library + * + * Copyright 2012, Mixpanel, Inc. All Rights Reserved + * http://mixpanel.com/ + * + * Includes portions of Underscore.js + * http://documentcloud.github.com/underscore/ + * (c) 2011 Jeremy Ashkenas, DocumentCloud Inc. + * Released under the MIT License. + */ + + // ==ClosureCompiler== + // @compilation_level ADVANCED_OPTIMIZATIONS + // @output_file_name mixpanel-2.8.min.js + // ==/ClosureCompiler== + + /* + SIMPLE STYLE GUIDE: + + this.x === public function + this._x === internal - only use within this file + this.__x === private - only use within the class + + Globals should be all caps + */ + + var init_type; // MODULE or SNIPPET loader + var mixpanel_master; // main mixpanel instance / object + var INIT_MODULE = 0; + var INIT_SNIPPET = 1; + + /* + * Constants + */ + /** @const */ var PRIMARY_INSTANCE_NAME = 'mixpanel'; + /** @const */ var SET_QUEUE_KEY = '__mps'; + /** @const */ var SET_ONCE_QUEUE_KEY = '__mpso'; + /** @const */ var UNSET_QUEUE_KEY = '__mpus'; + /** @const */ var ADD_QUEUE_KEY = '__mpa'; + /** @const */ var APPEND_QUEUE_KEY = '__mpap'; + /** @const */ var UNION_QUEUE_KEY = '__mpu'; + /** @const */ var SET_ACTION = '$set'; + /** @const */ var SET_ONCE_ACTION = '$set_once'; + /** @const */ var UNSET_ACTION = '$unset'; + /** @const */ var ADD_ACTION = '$add'; + /** @const */ var APPEND_ACTION = '$append'; + /** @const */ var UNION_ACTION = '$union'; + // This key is deprecated, but we want to check for it to see whether aliasing is allowed. + /** @const */ var PEOPLE_DISTINCT_ID_KEY = '$people_distinct_id'; + /** @const */ var ALIAS_ID_KEY = '__alias'; + /** @const */ var CAMPAIGN_IDS_KEY = '__cmpns'; + /** @const */ var EVENT_TIMERS_KEY = '__timers'; + /** @const */ var RESERVED_PROPERTIES = [ + SET_QUEUE_KEY, + SET_ONCE_QUEUE_KEY, + UNSET_QUEUE_KEY, + ADD_QUEUE_KEY, + APPEND_QUEUE_KEY, + UNION_QUEUE_KEY, + PEOPLE_DISTINCT_ID_KEY, + ALIAS_ID_KEY, + CAMPAIGN_IDS_KEY, + EVENT_TIMERS_KEY + ]; + + /* + * Dynamic... constants? Is that an oxymoron? + */ + // http://hacks.mozilla.org/2009/07/cross-site-xmlhttprequest-with-cors/ + // https://developer.mozilla.org/en-US/docs/DOM/XMLHttpRequest#withCredentials + var USE_XHR = (win.XMLHttpRequest && 'withCredentials' in new XMLHttpRequest()); + + // IE<10 does not support cross-origin XHR's but script tags + // with defer won't block window.onload; ENQUEUE_REQUESTS + // should only be true for Opera<12 + var ENQUEUE_REQUESTS = !USE_XHR && (userAgent.indexOf('MSIE') === -1) && (userAgent.indexOf('Mozilla') === -1); + + /* + * Module-level globals + */ + var DEFAULT_CONFIG = { + 'api_host': 'https://api.mixpanel.com', + 'app_host': 'https://mixpanel.com', + 'autotrack': true, + 'cdn': 'https://cdn.mxpnl.com', + 'cross_subdomain_cookie': true, + 'persistence': 'cookie', + 'persistence_name': '', + 'cookie_name': '', + 'loaded': function() {}, + 'store_google': true, + 'save_referrer': true, + 'test': false, + 'verbose': false, + 'img': false, + 'track_pageview': true, + 'debug': false, + 'track_links_timeout': 300, + 'cookie_expiration': 365, + 'upgrade': false, + 'disable_persistence': false, + 'disable_cookie': false, + 'secure_cookie': false, + 'ip': true, + 'property_blacklist': [] + }; + + var DOM_LOADED = false; + + /** + * DomTracker Object + * @constructor + */ + var DomTracker = function() {}; + + // interface + DomTracker.prototype.create_properties = function() {}; + DomTracker.prototype.event_handler = function() {}; + DomTracker.prototype.after_track_handler = function() {}; + + DomTracker.prototype.init = function(mixpanel_instance) { + this.mp = mixpanel_instance; + return this; + }; + + /** + * @param {Object|string} query + * @param {string} event_name + * @param {Object=} properties + * @param {function(...[*])=} user_callback + */ + DomTracker.prototype.track = function(query, event_name, properties, user_callback) { + var that = this; + var elements = _.dom_query(query); + + if (elements.length === 0) { + console$1.error('The DOM query (' + query + ') returned 0 elements'); + return; + } + + _.each(elements, function(element) { + _.register_event(element, this.override_event, function(e) { + var options = {}; + var props = that.create_properties(properties, this); + var timeout = that.mp.get_config('track_links_timeout'); + + that.event_handler(e, this, options); + + // in case the mixpanel servers don't get back to us in time + win.setTimeout(that.track_callback(user_callback, props, options, true), timeout); + + // fire the tracking event + that.mp.track(event_name, props, that.track_callback(user_callback, props, options)); + }); + }, this); + + return true; + }; + + /** + * @param {function(...[*])} user_callback + * @param {Object} props + * @param {boolean=} timeout_occured + */ + DomTracker.prototype.track_callback = function(user_callback, props, options, timeout_occured) { + timeout_occured = timeout_occured || false; + var that = this; + + return function() { + // options is referenced from both callbacks, so we can have + // a 'lock' of sorts to ensure only one fires + if (options.callback_fired) { return; } + options.callback_fired = true; + + if (user_callback && user_callback(timeout_occured, props) === false) { + // user can prevent the default functionality by + // returning false from their callback + return; + } + + that.after_track_handler(props, options, timeout_occured); + }; + }; + + DomTracker.prototype.create_properties = function(properties, element) { + var props; + + if (typeof(properties) === 'function') { + props = properties(element); + } else { + props = _.extend({}, properties); + } + + return props; + }; + + /** + * LinkTracker Object + * @constructor + * @extends DomTracker + */ + var LinkTracker = function() { + this.override_event = 'click'; + }; + _.inherit(LinkTracker, DomTracker); + + LinkTracker.prototype.create_properties = function(properties, element) { + var props = LinkTracker.superclass.create_properties.apply(this, arguments); + + if (element.href) { props['url'] = element.href; } + + return props; + }; + + LinkTracker.prototype.event_handler = function(evt, element, options) { + options.new_tab = ( + evt.which === 2 || + evt.metaKey || + evt.ctrlKey || + element.target === '_blank' + ); + options.href = element.href; + + if (!options.new_tab) { + evt.preventDefault(); + } + }; + + LinkTracker.prototype.after_track_handler = function(props, options) { + if (options.new_tab) { return; } + + setTimeout(function() { + win.location = options.href; + }, 0); + }; + + /** + * FormTracker Object + * @constructor + * @extends DomTracker + */ + var FormTracker = function() { + this.override_event = 'submit'; + }; + _.inherit(FormTracker, DomTracker); + + FormTracker.prototype.event_handler = function(evt, element, options) { + options.element = element; + evt.preventDefault(); + }; + + FormTracker.prototype.after_track_handler = function(props, options) { + setTimeout(function() { + options.element.submit(); + }, 0); + }; + + /** + * Mixpanel Persistence Object + * @constructor + */ + var MixpanelPersistence = function(config) { + this['props'] = {}; + this.campaign_params_saved = false; + + if (config['persistence_name']) { + this.name = 'mp_' + config['persistence_name']; + } else { + this.name = 'mp_' + config['token'] + '_mixpanel'; + } + + var storage_type = config['persistence']; + if (storage_type !== 'cookie' && storage_type !== 'localStorage') { + console$1.critical('Unknown persistence type ' + storage_type + '; falling back to cookie'); + storage_type = config['persistence'] = 'cookie'; + } + + var localStorage_supported = function() { + var supported = true; + try { + var key = '__mplssupport__', + val = 'xyz'; + _.localStorage.set(key, val); + if (_.localStorage.get(key) !== val) { + supported = false; + } + _.localStorage.remove(key); + } catch (err) { + supported = false; + } + if (!supported) { + console$1.error('localStorage unsupported; falling back to cookie store'); + } + return supported; + }; + if (storage_type === 'localStorage' && localStorage_supported()) { + this.storage = _.localStorage; + } else { + this.storage = _.cookie; + } + + this.load(); + this.update_config(config); + this.upgrade(config); + this.save(); + }; + + MixpanelPersistence.prototype.properties = function() { + var p = {}; + // Filter out reserved properties + _.each(this['props'], function(v, k) { + if (!_.include(RESERVED_PROPERTIES, k)) { + p[k] = v; + } + }); + return p; + }; + + MixpanelPersistence.prototype.load = function() { + if (this.disabled) { return; } + + var entry = this.storage.parse(this.name); + + if (entry) { + this['props'] = _.extend({}, entry); + } + }; + + MixpanelPersistence.prototype.upgrade = function(config) { + var upgrade_from_old_lib = config['upgrade'], + old_cookie_name, + old_cookie; + + if (upgrade_from_old_lib) { + old_cookie_name = 'mp_super_properties'; + // Case where they had a custom cookie name before. + if (typeof(upgrade_from_old_lib) === 'string') { + old_cookie_name = upgrade_from_old_lib; + } + + old_cookie = this.storage.parse(old_cookie_name); + + // remove the cookie + this.storage.remove(old_cookie_name); + this.storage.remove(old_cookie_name, true); + + if (old_cookie) { + this['props'] = _.extend( + this['props'], + old_cookie['all'], + old_cookie['events'] + ); + } + } + + if (!config['cookie_name'] && config['name'] !== 'mixpanel') { + // special case to handle people with cookies of the form + // mp_TOKEN_INSTANCENAME from the first release of this library + old_cookie_name = 'mp_' + config['token'] + '_' + config['name']; + old_cookie = this.storage.parse(old_cookie_name); + + if (old_cookie) { + this.storage.remove(old_cookie_name); + this.storage.remove(old_cookie_name, true); + + // Save the prop values that were in the cookie from before - + // this should only happen once as we delete the old one. + this.register_once(old_cookie); + } + } + + if (this.storage === _.localStorage) { + old_cookie = _.cookie.parse(this.name); + + _.cookie.remove(this.name); + _.cookie.remove(this.name, true); + + if (old_cookie) { + this.register_once(old_cookie); + } + } + }; + + MixpanelPersistence.prototype.save = function() { + if (this.disabled) { return; } + this._expire_notification_campaigns(); + this.storage.set( + this.name, + _.JSONEncode(this['props']), + this.expire_days, + this.cross_subdomain, + this.secure + ); + }; + + MixpanelPersistence.prototype.remove = function() { + // remove both domain and subdomain cookies + this.storage.remove(this.name, false); + this.storage.remove(this.name, true); + }; + + // removes the storage entry and deletes all loaded data + // forced name for tests + MixpanelPersistence.prototype.clear = function() { + this.remove(); + this['props'] = {}; + }; + + /** + * @param {Object} props + * @param {*=} default_value + * @param {number=} days + */ + MixpanelPersistence.prototype.register_once = function(props, default_value, days) { + if (_.isObject(props)) { + if (typeof(default_value) === 'undefined') { default_value = 'None'; } + this.expire_days = (typeof(days) === 'undefined') ? this.default_expiry : days; + + _.each(props, function(val, prop) { + if (!this['props'].hasOwnProperty(prop) || this['props'][prop] === default_value) { + this['props'][prop] = val; + } + }, this); + + this.save(); + + return true; + } + return false; + }; + + /** + * @param {Object} props + * @param {number=} days + */ + MixpanelPersistence.prototype.register = function(props, days) { + if (_.isObject(props)) { + this.expire_days = (typeof(days) === 'undefined') ? this.default_expiry : days; + + _.extend(this['props'], props); + + this.save(); + + return true; + } + return false; + }; + + MixpanelPersistence.prototype.unregister = function(prop) { + if (prop in this['props']) { + delete this['props'][prop]; + this.save(); + } + }; + + MixpanelPersistence.prototype._expire_notification_campaigns = _.safewrap(function() { + var campaigns_shown = this['props'][CAMPAIGN_IDS_KEY], + EXPIRY_TIME = Config.DEBUG ? 60 * 1000 : 60 * 60 * 1000; // 1 minute (Config.DEBUG) / 1 hour (PDXN) + if (!campaigns_shown) { + return; + } + for (var campaign_id in campaigns_shown) { + if (1 * new Date() - campaigns_shown[campaign_id] > EXPIRY_TIME) { + delete campaigns_shown[campaign_id]; + } + } + if (_.isEmptyObject(campaigns_shown)) { + delete this['props'][CAMPAIGN_IDS_KEY]; + } + }); + + MixpanelPersistence.prototype.update_campaign_params = function() { + if (!this.campaign_params_saved) { + this.register_once(_.info.campaignParams()); + this.campaign_params_saved = true; + } + }; + + MixpanelPersistence.prototype.update_search_keyword = function(referrer) { + this.register(_.info.searchInfo(referrer)); + }; + + // EXPORTED METHOD, we test this directly. + MixpanelPersistence.prototype.update_referrer_info = function(referrer) { + // If referrer doesn't exist, we want to note the fact that it was type-in traffic. + this.register_once({ + '$initial_referrer': referrer || '$direct', + '$initial_referring_domain': _.info.referringDomain(referrer) || '$direct' + }, ''); + }; + + MixpanelPersistence.prototype.get_referrer_info = function() { + return _.strip_empty_properties({ + '$initial_referrer': this['props']['$initial_referrer'], + '$initial_referring_domain': this['props']['$initial_referring_domain'] + }); + }; + + // safely fills the passed in object with stored properties, + // does not override any properties defined in both + // returns the passed in object + MixpanelPersistence.prototype.safe_merge = function(props) { + _.each(this['props'], function(val, prop) { + if (!(prop in props)) { + props[prop] = val; + } + }); + + return props; + }; + + MixpanelPersistence.prototype.update_config = function(config) { + this.default_expiry = this.expire_days = config['cookie_expiration']; + this.set_disabled(config['disable_persistence']); + this.set_cross_subdomain(config['cross_subdomain_cookie']); + this.set_secure(config['secure_cookie']); + }; + + MixpanelPersistence.prototype.set_disabled = function(disabled) { + this.disabled = disabled; + if (this.disabled) { + this.remove(); + } + }; + + MixpanelPersistence.prototype.set_cross_subdomain = function(cross_subdomain) { + if (cross_subdomain !== this.cross_subdomain) { + this.cross_subdomain = cross_subdomain; + this.remove(); + this.save(); + } + }; + + MixpanelPersistence.prototype.get_cross_subdomain = function() { + return this.cross_subdomain; + }; + + MixpanelPersistence.prototype.set_secure = function(secure) { + if (secure !== this.secure) { + this.secure = secure ? true : false; + this.remove(); + this.save(); + } + }; + + MixpanelPersistence.prototype._add_to_people_queue = function(queue, data) { + var q_key = this._get_queue_key(queue), + q_data = data[queue], + set_q = this._get_or_create_queue(SET_ACTION), + set_once_q = this._get_or_create_queue(SET_ONCE_ACTION), + unset_q = this._get_or_create_queue(UNSET_ACTION), + add_q = this._get_or_create_queue(ADD_ACTION), + union_q = this._get_or_create_queue(UNION_ACTION), + append_q = this._get_or_create_queue(APPEND_ACTION, []); + + if (q_key === SET_QUEUE_KEY) { + // Update the set queue - we can override any existing values + _.extend(set_q, q_data); + // if there was a pending increment, override it + // with the set. + this._pop_from_people_queue(ADD_ACTION, q_data); + // if there was a pending union, override it + // with the set. + this._pop_from_people_queue(UNION_ACTION, q_data); + this._pop_from_people_queue(UNSET_ACTION, q_data); + } else if (q_key === SET_ONCE_QUEUE_KEY) { + // only queue the data if there is not already a set_once call for it. + _.each(q_data, function(v, k) { + if (!(k in set_once_q)) { + set_once_q[k] = v; + } + }); + this._pop_from_people_queue(UNSET_ACTION, q_data); + } else if (q_key === UNSET_QUEUE_KEY) { + _.each(q_data, function(prop) { + + // undo previously-queued actions on this key + _.each([set_q, set_once_q, add_q, union_q], function(enqueued_obj) { + if (prop in enqueued_obj) { + delete enqueued_obj[prop]; + } + }); + _.each(append_q, function(append_obj) { + if (prop in append_obj) { + delete append_obj[prop]; + } + }); + + unset_q[prop] = true; + + }); + } else if (q_key === ADD_QUEUE_KEY) { + _.each(q_data, function(v, k) { + // If it exists in the set queue, increment + // the value + if (k in set_q) { + set_q[k] += v; + } else { + // If it doesn't exist, update the add + // queue + if (!(k in add_q)) { + add_q[k] = 0; + } + add_q[k] += v; + } + }, this); + this._pop_from_people_queue(UNSET_ACTION, q_data); + } else if (q_key === UNION_QUEUE_KEY) { + _.each(q_data, function(v, k) { + if (_.isArray(v)) { + if (!(k in union_q)) { + union_q[k] = []; + } + // We may send duplicates, the server will dedup them. + union_q[k] = union_q[k].concat(v); + } + }); + this._pop_from_people_queue(UNSET_ACTION, q_data); + } else if (q_key === APPEND_QUEUE_KEY) { + append_q.push(q_data); + this._pop_from_people_queue(UNSET_ACTION, q_data); + } + + console$1.log('MIXPANEL PEOPLE REQUEST (QUEUED, PENDING IDENTIFY):'); + console$1.log(data); + + this.save(); + }; + + MixpanelPersistence.prototype._pop_from_people_queue = function(queue, data) { + var q = this._get_queue(queue); + if (!_.isUndefined(q)) { + _.each(data, function(v, k) { + delete q[k]; + }, this); + + this.save(); + } + }; + + MixpanelPersistence.prototype._get_queue_key = function(queue) { + if (queue === SET_ACTION) { + return SET_QUEUE_KEY; + } else if (queue === SET_ONCE_ACTION) { + return SET_ONCE_QUEUE_KEY; + } else if (queue === UNSET_ACTION) { + return UNSET_QUEUE_KEY; + } else if (queue === ADD_ACTION) { + return ADD_QUEUE_KEY; + } else if (queue === APPEND_ACTION) { + return APPEND_QUEUE_KEY; + } else if (queue === UNION_ACTION) { + return UNION_QUEUE_KEY; + } else { + console$1.error('Invalid queue:', queue); + } + }; + + MixpanelPersistence.prototype._get_queue = function(queue) { + return this['props'][this._get_queue_key(queue)]; + }; + MixpanelPersistence.prototype._get_or_create_queue = function(queue, default_val) { + var key = this._get_queue_key(queue); + default_val = _.isUndefined(default_val) ? {} : default_val; + + return this['props'][key] || (this['props'][key] = default_val); + }; + + MixpanelPersistence.prototype.set_event_timer = function(event_name, timestamp) { + var timers = this['props'][EVENT_TIMERS_KEY] || {}; + timers[event_name] = timestamp; + this['props'][EVENT_TIMERS_KEY] = timers; + this.save(); + }; + + MixpanelPersistence.prototype.remove_event_timer = function(event_name) { + var timers = this['props'][EVENT_TIMERS_KEY] || {}; + var timestamp = timers[event_name]; + if (!_.isUndefined(timestamp)) { + delete this['props'][EVENT_TIMERS_KEY][event_name]; + this.save(); + } + return timestamp; + }; + + /** + * Mixpanel Library Object + * @constructor + */ + var MixpanelLib = function() {}; + + /** + * Mixpanel People Object + * @constructor + */ + var MixpanelPeople = function() {}; + + var MPNotif; + + /** + * create_mplib(token:string, config:object, name:string) + * + * This function is used by the init method of MixpanelLib objects + * as well as the main initializer at the end of the JSLib (that + * initializes document.mixpanel as well as any additional instances + * declared before this file has loaded). + */ + var create_mplib = function(token, config, name) { + var instance, + target = (name === PRIMARY_INSTANCE_NAME) ? mixpanel_master : mixpanel_master[name]; + + if (target && init_type === INIT_MODULE) { + instance = target; + } else { + if (target && !_.isArray(target)) { + console$1.error('You have already initialized ' + name); + return; + } + instance = new MixpanelLib(); + } + + instance._init(token, config, name); + + instance['people'] = new MixpanelPeople(); + instance['people']._init(instance); + + // if any instance on the page has debug = true, we set the + // global debug to be true + Config.DEBUG = Config.DEBUG || instance.get_config('debug'); + + instance['__autotrack_enabled'] = instance.get_config('autotrack'); + if (instance.get_config('autotrack')) { + var num_buckets = 100; + var num_enabled_buckets = 100; + if (!autotrack.enabledForProject(instance.get_config('token'), num_buckets, num_enabled_buckets)) { + instance['__autotrack_enabled'] = false; + console$1.log('Not in active bucket: disabling Automatic Event Collection.'); + } else if (!autotrack.isBrowserSupported()) { + instance['__autotrack_enabled'] = false; + console$1.log('Disabling Automatic Event Collection because this browser is not supported'); + } else { + autotrack.init(instance); + } + + try { + add_dom_event_counting_handlers(instance); + } catch (e) { + console$1.error(e); + } + } + + // if target is not defined, we called init after the lib already + // loaded, so there won't be an array of things to execute + if (!_.isUndefined(target) && _.isArray(target)) { + // Crunch through the people queue first - we queue this data up & + // flush on identify, so it's better to do all these operations first + instance._execute_array.call(instance['people'], target['people']); + instance._execute_array(target); + } + + return instance; + }; + + // Initialization methods + + /** + * This function initializes a new instance of the Mixpanel tracking object. + * All new instances are added to the main mixpanel object as sub properties (such as + * mixpanel.library_name) and also returned by this function. To define a + * second instance on the page, you would call: + * + * mixpanel.init('new token', { your: 'config' }, 'library_name'); + * + * and use it like so: + * + * mixpanel.library_name.track(...); + * + * @param {String} token Your Mixpanel API token + * @param {Object} [config] A dictionary of config options to override.
See a list of default config options. + * @param {String} [name] The name for the new mixpanel instance that you want created + */ + MixpanelLib.prototype.init = function (token, config, name) { + if (_.isUndefined(name)) { + console$1.error('You must name your new library: init(token, config, name)'); + return; + } + if (name === PRIMARY_INSTANCE_NAME) { + console$1.error('You must initialize the main mixpanel object right after you include the Mixpanel js snippet'); + return; + } + + var instance = create_mplib(token, config, name); + mixpanel_master[name] = instance; + instance._loaded(); + + return instance; + }; + + // mixpanel._init(token:string, config:object, name:string) + // + // This function sets up the current instance of the mixpanel + // library. The difference between this method and the init(...) + // method is this one initializes the actual instance, whereas the + // init(...) method sets up a new library and calls _init on it. + // + MixpanelLib.prototype._init = function(token, config, name) { + this['__loaded'] = true; + this['config'] = {}; + + this.set_config(_.extend({}, DEFAULT_CONFIG, config, { + 'name': name, + 'token': token, + 'callback_fn': ((name === PRIMARY_INSTANCE_NAME) ? name : PRIMARY_INSTANCE_NAME + '.' + name) + '._jsc' + })); + + this['_jsc'] = function() {}; + + this.__dom_loaded_queue = []; + this.__request_queue = []; + this.__disabled_events = []; + this._flags = { + 'disable_all_events': false, + 'identify_called': false + }; + + this['persistence'] = this['cookie'] = new MixpanelPersistence(this['config']); + this.register_once({'distinct_id': _.UUID()}, ''); + }; + + // Private methods + + MixpanelLib.prototype._loaded = function() { + this.get_config('loaded')(this); + + // this happens after so a user can call identify/name_tag in + // the loaded callback + if (this.get_config('track_pageview')) { + this.track_pageview(); + } + }; + + MixpanelLib.prototype._dom_loaded = function() { + _.each(this.__dom_loaded_queue, function(item) { + this._track_dom.apply(this, item); + }, this); + _.each(this.__request_queue, function(item) { + this._send_request.apply(this, item); + }, this); + delete this.__dom_loaded_queue; + delete this.__request_queue; + }; + + MixpanelLib.prototype._track_dom = function(DomClass, args) { + if (this.get_config('img')) { + console$1.error('You can\'t use DOM tracking functions with img = true.'); + return false; + } + + if (!DOM_LOADED) { + this.__dom_loaded_queue.push([DomClass, args]); + return false; + } + + var dt = new DomClass().init(this); + return dt.track.apply(dt, args); + }; + + /** + * _prepare_callback() should be called by callers of _send_request for use + * as the callback argument. + * + * If there is no callback, this returns null. + * If we are going to make XHR/XDR requests, this returns a function. + * If we are going to use script tags, this returns a string to use as the + * callback GET param. + */ + MixpanelLib.prototype._prepare_callback = function(callback, data) { + if (_.isUndefined(callback)) { + return null; + } + + if (USE_XHR) { + var callback_function = function(response) { + callback(response, data); + }; + return callback_function; + } else { + // if the user gives us a callback, we store as a random + // property on this instances jsc function and update our + // callback string to reflect that. + var jsc = this['_jsc']; + var randomized_cb = '' + Math.floor(Math.random() * 100000000); + var callback_string = this.get_config('callback_fn') + '[' + randomized_cb + ']'; + jsc[randomized_cb] = function(response) { + delete jsc[randomized_cb]; + callback(response, data); + }; + return callback_string; + } + }; + + MixpanelLib.prototype._send_request = function(url, data, callback) { + if (ENQUEUE_REQUESTS) { + this.__request_queue.push(arguments); + return; + } + + // needed to correctly format responses + var verbose_mode = this.get_config('verbose'); + if (data['verbose']) { verbose_mode = true; } + + if (this.get_config('test')) { data['test'] = 1; } + if (verbose_mode) { data['verbose'] = 1; } + if (this.get_config('img')) { data['img'] = 1; } + if (!USE_XHR) { + if (callback) { + data['callback'] = callback; + } else if (verbose_mode || this.get_config('test')) { + // Verbose output (from verbose mode, or an error in test mode) is a json blob, + // which by itself is not valid javascript. Without a callback, this verbose output will + // cause an error when returned via jsonp, so we force a no-op callback param. + // See the ECMA script spec: http://www.ecma-international.org/ecma-262/5.1/#sec-12.4 + data['callback'] = '(function(){})'; + } + } + + data['ip'] = this.get_config('ip')?1:0; + data['_'] = new Date().getTime().toString(); + url += '?' + _.HTTPBuildQuery(data); + + if ('img' in data) { + var img = document$1.createElement('img'); + img.src = url; + document$1.body.appendChild(img); + } else if (USE_XHR) { + try { + var req = new XMLHttpRequest(); + req.open('GET', url, true); + // send the mp_optout cookie + // withCredentials cannot be modified until after calling .open on Android and Mobile Safari + req.withCredentials = true; + req.onreadystatechange = function () { + if (req.readyState === 4) { // XMLHttpRequest.DONE == 4, except in safari 4 + if (req.status === 200) { + if (callback) { + if (verbose_mode) { + var response; + try { + response = _.JSONDecode(req.responseText); + } catch (e) { + console$1.error(e); + return; + } + callback(response); + } else { + callback(Number(req.responseText)); + } + } + } else { + var error = 'Bad HTTP status: ' + req.status + ' ' + req.statusText; + console$1.error(error); + if (callback) { + if (verbose_mode) { + callback({status: 0, error: error}); + } else { + callback(0); + } + } + } + } + }; + req.send(null); + } catch (e) { + console$1.error(e); + } + } else { + var script = document$1.createElement('script'); + script.type = 'text/javascript'; + script.async = true; + script.defer = true; + script.src = url; + var s = document$1.getElementsByTagName('script')[0]; + s.parentNode.insertBefore(script, s); + } + }; + + /** + * _execute_array() deals with processing any mixpanel function + * calls that were called before the Mixpanel library were loaded + * (and are thus stored in an array so they can be called later) + * + * Note: we fire off all the mixpanel function calls && user defined + * functions BEFORE we fire off mixpanel tracking calls. This is so + * identify/register/set_config calls can properly modify early + * tracking calls. + * + * @param {Array} array + */ + MixpanelLib.prototype._execute_array = function(array) { + var fn_name, alias_calls = [], other_calls = [], tracking_calls = []; + _.each(array, function(item) { + if (item) { + fn_name = item[0]; + if (typeof(item) === 'function') { + item.call(this); + } else if (_.isArray(item) && fn_name === 'alias') { + alias_calls.push(item); + } else if (_.isArray(item) && fn_name.indexOf('track') !== -1 && typeof(this[fn_name]) === 'function') { + tracking_calls.push(item); + } else { + other_calls.push(item); + } + } + }, this); + + var execute = function(calls, context) { + _.each(calls, function(item) { + this[item[0]].apply(this, item.slice(1)); + }, context); + }; + + execute(alias_calls, this); + execute(other_calls, this); + execute(tracking_calls, this); + }; + + /** + * push() keeps the standard async-array-push + * behavior around after the lib is loaded. + * This is only useful for external integrations that + * do not wish to rely on our convenience methods + * (created in the snippet). + * + * ### Usage: + * mixpanel.push(['register', { a: 'b' }]); + * + * @param {Array} item A [function_name, args...] array to be executed + */ + MixpanelLib.prototype.push = function(item) { + this._execute_array([item]); + }; + + /** + * Disable events on the Mixpanel object. If passed no arguments, + * this function disables tracking of any event. If passed an + * array of event names, those events will be disabled, but other + * events will continue to be tracked. + * + * Note: this function does not stop other mixpanel functions from + * firing, such as register() or people.set(). + * + * @param {Array} [events] An array of event names to disable + */ + MixpanelLib.prototype.disable = function(events) { + if (typeof(events) === 'undefined') { + this._flags.disable_all_events = true; + } else { + this.__disabled_events = this.__disabled_events.concat(events); + } + }; + + /** + * Track an event. This is the most important and + * frequently used Mixpanel function. + * + * ### Usage: + * + * // track an event named 'Registered' + * mixpanel.track('Registered', {'Gender': 'Male', 'Age': 21}); + * + * To track link clicks or form submissions, see track_links() or track_forms(). + * + * @param {String} event_name The name of the event. This can be anything the user does - 'Button Click', 'Sign Up', 'Item Purchased', etc. + * @param {Object} [properties] A set of properties to include with the event you're sending. These describe the user who did the event or details about the event itself. + * @param {Function} [callback] If provided, the callback function will be called after tracking the event. + */ + MixpanelLib.prototype.track = function(event_name, properties, callback) { + if (typeof(callback) !== 'function') { + callback = function() {}; + } + + if (_.isUndefined(event_name)) { + console$1.error('No event name provided to mixpanel.track'); + return; + } + + if (this._event_is_disabled(event_name)) { + callback(0); + return; + } + + // set defaults + properties = properties || {}; + properties['token'] = this.get_config('token'); + + // set $duration if time_event was previously called for this event + var start_timestamp = this['persistence'].remove_event_timer(event_name); + if (!_.isUndefined(start_timestamp)) { + var duration_in_ms = new Date().getTime() - start_timestamp; + properties['$duration'] = parseFloat((duration_in_ms / 1000).toFixed(3)); + } + + // update persistence + this['persistence'].update_search_keyword(document$1.referrer); + + if (this.get_config('store_google')) { this['persistence'].update_campaign_params(); } + if (this.get_config('save_referrer')) { this['persistence'].update_referrer_info(document$1.referrer); } + + // note: extend writes to the first object, so lets make sure we + // don't write to the persistence properties object and info + // properties object by passing in a new object + + // update properties with pageview info and super-properties + properties = _.extend( + {}, + _.info.properties(), + this['persistence'].properties(), + properties + ); + + try { + if (this.get_config('autotrack') && event_name !== 'mp_page_view' && event_name !== '$create_alias') { + // The point of $__c is to count how many clicks occur per tracked event. Since we're + // tracking an event in this function, we need to reset the $__c value. + properties = _.extend({}, properties, this.mp_counts); + this.mp_counts = {'$__c': 0}; + _.cookie.set('mp_' + this.get_config('name') + '__c', 0, 1, true); + } + } catch (e) { + console$1.error(e); + } + + var property_blacklist = this.get_config('property_blacklist'); + if (_.isArray(property_blacklist)) { + _.each(property_blacklist, function(blacklisted_prop) { + delete properties[blacklisted_prop]; + }); + } else { + console$1.error('Invalid value for property_blacklist config: ' + property_blacklist); + } + + var data = { + 'event': event_name, + 'properties': properties + }; + + var truncated_data = _.truncate(data, 255); + var json_data = _.JSONEncode(truncated_data); + var encoded_data = _.base64Encode(json_data); + + console$1.log('MIXPANEL REQUEST:'); + console$1.log(truncated_data); + + this._send_request( + this.get_config('api_host') + '/track/', + { 'data': encoded_data }, + this._prepare_callback(callback, truncated_data) + ); + + return truncated_data; + }; + + /** + * Track a page view event, which is currently ignored by the server. + * This function is called by default on page load unless the + * track_pageview configuration variable is false. + * + * @param {String} [page] The url of the page to record. If you don't include this, it defaults to the current url. + * @api private + */ + MixpanelLib.prototype.track_pageview = function(page) { + if (_.isUndefined(page)) { + page = document$1.location.href; + } + this.track('mp_page_view', _.info.pageviewInfo(page)); + }; + + /** + * Track clicks on a set of document elements. Selector must be a + * valid query. Elements must exist on the page at the time track_links is called. + * + * ### Usage: + * + * // track click for link id #nav + * mixpanel.track_links('#nav', 'Clicked Nav Link'); + * + * ### Notes: + * + * This function will wait up to 300 ms for the Mixpanel + * servers to respond. If they have not responded by that time + * it will head to the link without ensuring that your event + * has been tracked. To configure this timeout please see the + * set_config() documentation below. + * + * If you pass a function in as the properties argument, the + * function will receive the DOMElement that triggered the + * event as an argument. You are expected to return an object + * from the function; any properties defined on this object + * will be sent to mixpanel as event properties. + * + * @type {Function} + * @param {Object|String} query A valid DOM query, element or jQuery-esque list + * @param {String} event_name The name of the event to track + * @param {Object|Function} [properties] A properties object or function that returns a dictionary of properties when passed a DOMElement + */ + MixpanelLib.prototype.track_links = function() { + return this._track_dom.call(this, LinkTracker, arguments); + }; + + /** + * Track form submissions. Selector must be a valid query. + * + * ### Usage: + * + * // track submission for form id 'register' + * mixpanel.track_forms('#register', 'Created Account'); + * + * ### Notes: + * + * This function will wait up to 300 ms for the mixpanel + * servers to respond, if they have not responded by that time + * it will head to the link without ensuring that your event + * has been tracked. To configure this timeout please see the + * set_config() documentation below. + * + * If you pass a function in as the properties argument, the + * function will receive the DOMElement that triggered the + * event as an argument. You are expected to return an object + * from the function; any properties defined on this object + * will be sent to mixpanel as event properties. + * + * @type {Function} + * @param {Object|String} query A valid DOM query, element or jQuery-esque list + * @param {String} event_name The name of the event to track + * @param {Object|Function} [properties] This can be a set of properties, or a function that returns a set of properties after being passed a DOMElement + */ + MixpanelLib.prototype.track_forms = function() { + return this._track_dom.call(this, FormTracker, arguments); + }; + + /** + * Time an event by including the time between this call and a + * later 'track' call for the same event in the properties sent + * with the event. + * + * ### Usage: + * + * // time an event named 'Registered' + * mixpanel.time_event('Registered'); + * mixpanel.track('Registered', {'Gender': 'Male', 'Age': 21}); + * + * When called for a particular event name, the next track call for that event + * name will include the elapsed time between the 'time_event' and 'track' + * calls. This value is stored as seconds in the '$duration' property. + * + * @param {String} event_name The name of the event. + */ + MixpanelLib.prototype.time_event = function(event_name) { + if (_.isUndefined(event_name)) { + console$1.error('No event name provided to mixpanel.time_event'); + return; + } + + if (this._event_is_disabled(event_name)) { + return; + } + + this['persistence'].set_event_timer(event_name, new Date().getTime()); + }; + + /** + * Register a set of super properties, which are included with all + * events. This will overwrite previous super property values. + * + * ### Usage: + * + * // register 'Gender' as a super property + * mixpanel.register({'Gender': 'Female'}); + * + * // register several super properties when a user signs up + * mixpanel.register({ + * 'Email': 'jdoe@example.com', + * 'Account Type': 'Free' + * }); + * + * @param {Object} properties An associative array of properties to store about the user + * @param {Number} [days] How many days since the user's last visit to store the super properties + */ + MixpanelLib.prototype.register = function(props, days) { + this['persistence'].register(props, days); + }; + + /** + * Register a set of super properties only once. This will not + * overwrite previous super property values, unlike register(). + * + * ### Usage: + * + * // register a super property for the first time only + * mixpanel.register_once({ + * 'First Login Date': new Date().toISOString() + * }); + * + * ### Notes: + * + * If default_value is specified, current super properties + * with that value will be overwritten. + * + * @param {Object} properties An associative array of properties to store about the user + * @param {*} [default_value] Value to override if already set in super properties (ex: 'False') Default: 'None' + * @param {Number} [days] How many days since the users last visit to store the super properties + */ + MixpanelLib.prototype.register_once = function(props, default_value, days) { + this['persistence'].register_once(props, default_value, days); + }; + + /** + * Delete a super property stored with the current user. + * + * @param {String} property The name of the super property to remove + */ + MixpanelLib.prototype.unregister = function(property) { + this['persistence'].unregister(property); + }; + + MixpanelLib.prototype._register_single = function(prop, value) { + var props = {}; + props[prop] = value; + this.register(props); + }; + + /** + * Identify a user with a unique ID instead of a Mixpanel + * randomly generated distinct_id. If the method is never called, + * then unique visitors will be identified by a UUID generated + * the first time they visit the site. + * + * ### Notes: + * + * You can call this function to overwrite a previously set + * unique ID for the current user. Mixpanel cannot translate + * between IDs at this time, so when you change a user's ID + * they will appear to be a new user. + * + * When used alone, mixpanel.identify will change the user's + * distinct_id to the unique ID provided. When used in tandem + * with mixpanel.alias, it will allow you to identify based on + * unique ID and map that back to the original, anonymous + * distinct_id given to the user upon her first arrival to your + * site (thus connecting anonymous pre-signup activity to + * post-signup activity). Though the two work together, do not + * call identify() at the same time as alias(). Calling the two + * at the same time can cause a race condition, so it is best + * practice to call identify on the original, anonymous ID + * right after you've aliased it. + * Learn more about how mixpanel.identify and mixpanel.alias can be used. + * + * @param {String} [unique_id] A string that uniquely identifies a user. If not provided, the distinct_id currently in the persistent store (cookie or localStorage) will be used. + */ + MixpanelLib.prototype.identify = function( + unique_id, _set_callback, _add_callback, _append_callback, _set_once_callback, _union_callback, _unset_callback + ) { + // Optional Parameters + // _set_callback:function A callback to be run if and when the People set queue is flushed + // _add_callback:function A callback to be run if and when the People add queue is flushed + // _append_callback:function A callback to be run if and when the People append queue is flushed + // _set_once_callback:function A callback to be run if and when the People set_once queue is flushed + // _union_callback:function A callback to be run if and when the People union queue is flushed + // _unset_callback:function A callback to be run if and when the People unset queue is flushed + + // identify only changes the distinct id if it doesn't match either the existing or the alias; + // if it's new, blow away the alias as well. + if (unique_id !== this.get_distinct_id() && unique_id !== this.get_property(ALIAS_ID_KEY)) { + this.unregister(ALIAS_ID_KEY); + this._register_single('distinct_id', unique_id); + } + this._check_and_handle_notifications(this.get_distinct_id()); + this._flags.identify_called = true; + // Flush any queued up people requests + this['people']._flush(_set_callback, _add_callback, _append_callback, _set_once_callback, _union_callback, _unset_callback); + }; + + /** + * Clears super properties and generates a new random distinct_id for this instance. + * Useful for clearing data when a user logs out. + */ + MixpanelLib.prototype.reset = function() { + this['persistence'].clear(); + this._flags.identify_called = false; + this.register_once({'distinct_id': _.UUID()}, ''); + }; + + /** + * Returns the current distinct id of the user. This is either the id automatically + * generated by the library or the id that has been passed by a call to identify(). + * + * ### Notes: + * + * get_distinct_id() can only be called after the Mixpanel library has finished loading. + * init() has a loaded function available to handle this automatically. For example: + * + * // set distinct_id after the mixpanel library has loaded + * mixpanel.init('YOUR PROJECT TOKEN', { + * loaded: function(mixpanel) { + * distinct_id = mixpanel.get_distinct_id(); + * } + * }); + */ + MixpanelLib.prototype.get_distinct_id = function() { + return this.get_property('distinct_id'); + }; + + /** + * Create an alias, which Mixpanel will use to link two distinct_ids going forward (not retroactively). + * Multiple aliases can map to the same original ID, but not vice-versa. Aliases can also be chained - the + * following is a valid scenario: + * + * mixpanel.alias('new_id', 'existing_id'); + * ... + * mixpanel.alias('newer_id', 'new_id'); + * + * If the original ID is not passed in, we will use the current distinct_id - probably the auto-generated GUID. + * + * ### Notes: + * + * The best practice is to call alias() when a unique ID is first created for a user + * (e.g., when a user first registers for an account and provides an email address). + * alias() should never be called more than once for a given user, except to + * chain a newer ID to a previously new ID, as described above. + * + * @param {String} alias A unique identifier that you want to use for this user in the future. + * @param {String} [original] The current identifier being used for this user. + */ + MixpanelLib.prototype.alias = function(alias, original) { + // If the $people_distinct_id key exists in persistence, there has been a previous + // mixpanel.people.identify() call made for this user. It is VERY BAD to make an alias with + // this ID, as it will duplicate users. + if (alias === this.get_property(PEOPLE_DISTINCT_ID_KEY)) { + console$1.critical('Attempting to create alias for existing People user - aborting.'); + return -2; + } + + var _this = this; + if (_.isUndefined(original)) { + original = this.get_distinct_id(); + } + if (alias !== original) { + this._register_single(ALIAS_ID_KEY, alias); + return this.track('$create_alias', { 'alias': alias, 'distinct_id': original }, function() { + // Flush the people queue + _this.identify(alias); + }); + } else { + console$1.error('alias matches current distinct_id - skipping api call.'); + this.identify(alias); + return -1; + } + }; + + /** + * Provide a string to recognize the user by. The string passed to + * this method will appear in the Mixpanel Streams product rather + * than an automatically generated name. Name tags do not have to + * be unique. + * + * This value will only be included in Streams data. + * + * @param {String} name_tag A human readable name for the user + * @api private + */ + MixpanelLib.prototype.name_tag = function(name_tag) { + this._register_single('mp_name_tag', name_tag); + }; + + /** + * Update the configuration of a mixpanel library instance. + * + * The default config is: + * + * { + * // super properties cookie expiration (in days) + * cookie_expiration: 365 + * + * // super properties span subdomains + * cross_subdomain_cookie: true + * + * // debug mode + * debug: false + * + * // if this is true, the mixpanel cookie or localStorage entry + * // will be deleted, and no user persistence will take place + * disable_persistence: false + * + * // if this is true, Mixpanel will automatically determine + * // City, Region and Country data using the IP address of + * //the client + * ip: true + * + * // type of persistent store for super properties (cookie/ + * // localStorage) if set to 'localStorage', any existing + * // mixpanel cookie value with the same persistence_name + * // will be transferred to localStorage and deleted + * persistence: 'cookie' + * + * // name for super properties persistent store + * persistence_name: '' + * + * // names of properties/superproperties which should never + * // be sent with track() calls + * property_blacklist: [] + * + * // if this is true, mixpanel cookies will be marked as + * // secure, meaning they will only be transmitted over https + * secure_cookie: false + * + * // the amount of time track_links will + * // wait for Mixpanel's servers to respond + * track_links_timeout: 300 + * + * // should we track a page view on page load + * track_pageview: true + * + * // if you set upgrade to be true, the library will check for + * // a cookie from our old js library and import super + * // properties from it, then the old cookie is deleted + * // The upgrade config option only works in the initialization, + * // so make sure you set it when you create the library. + * upgrade: false + * } + * + * + * @param {Object} config A dictionary of new configuration values to update + */ + MixpanelLib.prototype.set_config = function(config) { + if (_.isObject(config)) { + _.extend(this['config'], config); + + if (!this.get_config('persistence_name')) { + this['config']['persistence_name'] = this['config']['cookie_name']; + } + if (!this.get_config('disable_persistence')) { + this['config']['disable_persistence'] = this['config']['disable_cookie']; + } + + if (this['persistence']) { + this['persistence'].update_config(this['config']); + } + Config.DEBUG = Config.DEBUG || this.get_config('debug'); + } + }; + + /** + * returns the current config object for the library. + */ + MixpanelLib.prototype.get_config = function(prop_name) { + return this['config'][prop_name]; + }; + + /** + * Returns the value of the super property named property_name. If no such + * property is set, get_property() will return the undefined value. + * + * ### Notes: + * + * get_property() can only be called after the Mixpanel library has finished loading. + * init() has a loaded function available to handle this automatically. For example: + * + * // grab value for 'user_id' after the mixpanel library has loaded + * mixpanel.init('YOUR PROJECT TOKEN', { + * loaded: function(mixpanel) { + * user_id = mixpanel.get_property('user_id'); + * } + * }); + * + * @param {String} property_name The name of the super property you want to retrieve + */ + MixpanelLib.prototype.get_property = function(property_name) { + return this['persistence']['props'][property_name]; + }; + + MixpanelLib.prototype.toString = function() { + var name = this.get_config('name'); + if (name !== PRIMARY_INSTANCE_NAME) { + name = PRIMARY_INSTANCE_NAME + '.' + name; + } + return name; + }; + + MixpanelLib.prototype._event_is_disabled = function(event_name) { + return _.isBlockedUA(userAgent) || + this._flags.disable_all_events || + _.include(this.__disabled_events, event_name); + }; + + MixpanelLib.prototype._check_and_handle_notifications = function(distinct_id) { + if (!distinct_id || this._flags.identify_called || this.get_config('disable_notifications')) { + return; + } + + console$1.log('MIXPANEL NOTIFICATION CHECK'); + + var data = { + 'verbose': true, + 'version': '2', + 'lib': 'web', + 'token': this.get_config('token'), + 'distinct_id': distinct_id + }; + var self = this; + this._send_request( + this.get_config('api_host') + '/decide/', + data, + this._prepare_callback(function(r) { + if (r['notifications'] && r['notifications'].length > 0) { + self._show_notification.call(self, r['notifications'][0]); + } + }) + ); + }; + + MixpanelLib.prototype._show_notification = function(notification_data) { + var notification = new MPNotif(notification_data, this); + notification.show(); + }; + + MixpanelPeople.prototype._init = function(mixpanel_instance) { + this._mixpanel = mixpanel_instance; + }; + + /* + * Set properties on a user record. + * + * ### Usage: + * + * mixpanel.people.set('gender', 'm'); + * + * // or set multiple properties at once + * mixpanel.people.set({ + * 'Company': 'Acme', + * 'Plan': 'Premium', + * 'Upgrade date': new Date() + * }); + * // properties can be strings, integers, dates, or lists + * + * @param {Object|String} prop If a string, this is the name of the property. If an object, this is an associative array of names and values. + * @param {*} [to] A value to set on the given property name + * @param {Function} [callback] If provided, the callback will be called after the tracking event + */ + MixpanelPeople.prototype.set = function(prop, to, callback) { + var data = {}; + var $set = {}; + if (_.isObject(prop)) { + _.each(prop, function(v, k) { + if (!this._is_reserved_property(k)) { + $set[k] = v; + } + }, this); + callback = to; + } else { + $set[prop] = to; + } + + // make sure that the referrer info has been updated and saved + if (this._get_config('save_referrer')) { + this._mixpanel['persistence'].update_referrer_info(document$1.referrer); + } + + // update $set object with default people properties + $set = _.extend( + {}, + _.info.people_properties(), + this._mixpanel['persistence'].get_referrer_info(), + $set + ); + + data[SET_ACTION] = $set; + + return this._send_request(data, callback); + }; + + /* + * Set properties on a user record, only if they do not yet exist. + * This will not overwrite previous people property values, unlike + * people.set(). + * + * ### Usage: + * + * mixpanel.people.set_once('First Login Date', new Date()); + * + * // or set multiple properties at once + * mixpanel.people.set_once({ + * 'First Login Date': new Date(), + * 'Starting Plan': 'Premium' + * }); + * + * // properties can be strings, integers or dates + * + * @param {Object|String} prop If a string, this is the name of the property. If an object, this is an associative array of names and values. + * @param {*} [to] A value to set on the given property name + * @param {Function} [callback] If provided, the callback will be called after the tracking event + */ + MixpanelPeople.prototype.set_once = function(prop, to, callback) { + var data = {}; + var $set_once = {}; + if (_.isObject(prop)) { + _.each(prop, function(v, k) { + if (!this._is_reserved_property(k)) { + $set_once[k] = v; + } + }, this); + callback = to; + } else { + $set_once[prop] = to; + } + data[SET_ONCE_ACTION] = $set_once; + return this._send_request(data, callback); + }; + + /* + * Unset properties on a user record (permanently removes the properties and their values from a profile). + * + * ### Usage: + * + * mixpanel.people.unset('gender'); + * + * // or unset multiple properties at once + * mixpanel.people.unset(['gender', 'Company']); + * + * @param {Array|String} prop If a string, this is the name of the property. If an array, this is a list of property names. + * @param {Function} [callback] If provided, the callback will be called after the tracking event + */ + MixpanelPeople.prototype.unset = function(prop, callback) { + var data = {}; + var $unset = []; + if (!_.isArray(prop)) { + prop = [prop]; + } + + _.each(prop, function(k) { + if (!this._is_reserved_property(k)) { + $unset.push(k); + } + }, this); + + data[UNSET_ACTION] = $unset; + + return this._send_request(data, callback); + }; + + /* + * Increment/decrement numeric people analytics properties. + * + * ### Usage: + * + * mixpanel.people.increment('page_views', 1); + * + * // or, for convenience, if you're just incrementing a counter by + * // 1, you can simply do + * mixpanel.people.increment('page_views'); + * + * // to decrement a counter, pass a negative number + * mixpanel.people.increment('credits_left', -1); + * + * // like mixpanel.people.set(), you can increment multiple + * // properties at once: + * mixpanel.people.increment({ + * counter1: 1, + * counter2: 6 + * }); + * + * @param {Object|String} prop If a string, this is the name of the property. If an object, this is an associative array of names and numeric values. + * @param {Number} [by] An amount to increment the given property + * @param {Function} [callback] If provided, the callback will be called after the tracking event + */ + MixpanelPeople.prototype.increment = function(prop, by, callback) { + var data = {}; + var $add = {}; + if (_.isObject(prop)) { + _.each(prop, function(v, k) { + if (!this._is_reserved_property(k)) { + if (isNaN(parseFloat(v))) { + console$1.error('Invalid increment value passed to mixpanel.people.increment - must be a number'); + return; + } else { + $add[k] = v; + } + } + }, this); + callback = by; + } else { + // convenience: mixpanel.people.increment('property'); will + // increment 'property' by 1 + if (_.isUndefined(by)) { + by = 1; + } + $add[prop] = by; + } + data[ADD_ACTION] = $add; + + return this._send_request(data, callback); + }; + + /* + * Append a value to a list-valued people analytics property. + * + * ### Usage: + * + * // append a value to a list, creating it if needed + * mixpanel.people.append('pages_visited', 'homepage'); + * + * // like mixpanel.people.set(), you can append multiple + * // properties at once: + * mixpanel.people.append({ + * list1: 'bob', + * list2: 123 + * }); + * + * @param {Object|String} prop If a string, this is the name of the property. If an object, this is an associative array of names and values. + * @param {*} [value] An item to append to the list + * @param {Function} [callback] If provided, the callback will be called after the tracking event + */ + MixpanelPeople.prototype.append = function(list_name, value, callback) { + var data = {}; + var $append = {}; + if (_.isObject(list_name)) { + _.each(list_name, function(v, k) { + if (!this._is_reserved_property(k)) { + $append[k] = v; + } + }, this); + callback = value; + } else { + $append[list_name] = value; + } + data[APPEND_ACTION] = $append; + + return this._send_request(data, callback); + }; + + /* + * Merge a given list with a list-valued people analytics property, + * excluding duplicate values. + * + * ### Usage: + * + * // merge a value to a list, creating it if needed + * mixpanel.people.union('pages_visited', 'homepage'); + * + * // like mixpanel.people.set(), you can append multiple + * // properties at once: + * mixpanel.people.union({ + * list1: 'bob', + * list2: 123 + * }); + * + * // like mixpanel.people.append(), you can append multiple + * // values to the same list: + * mixpanel.people.union({ + * list1: ['bob', 'billy'] + * }); + * + * @param {Object|String} prop If a string, this is the name of the property. If an object, this is an associative array of names and values. + * @param {*} [value] Value / values to merge with the given property + * @param {Function} [callback] If provided, the callback will be called after the tracking event + */ + MixpanelPeople.prototype.union = function(list_name, values, callback) { + var data = {}; + var $union = {}; + if (_.isObject(list_name)) { + _.each(list_name, function(v, k) { + if (!this._is_reserved_property(k)) { + $union[k] = _.isArray(v) ? v : [v]; + } + }, this); + callback = values; + } else { + $union[list_name] = _.isArray(values) ? values : [values]; + } + data[UNION_ACTION] = $union; + + return this._send_request(data, callback); + }; + + /* + * Record that you have charged the current user a certain amount + * of money. Charges recorded with track_charge() will appear in the + * Mixpanel revenue report. + * + * ### Usage: + * + * // charge a user $50 + * mixpanel.people.track_charge(50); + * + * // charge a user $30.50 on the 2nd of january + * mixpanel.people.track_charge(30.50, { + * '$time': new Date('jan 1 2012') + * }); + * + * @param {Number} amount The amount of money charged to the current user + * @param {Object} [properties] An associative array of properties associated with the charge + * @param {Function} [callback] If provided, the callback will be called when the server responds + */ + MixpanelPeople.prototype.track_charge = function(amount, properties, callback) { + if (!_.isNumber(amount)) { + amount = parseFloat(amount); + if (isNaN(amount)) { + console$1.error('Invalid value passed to mixpanel.people.track_charge - must be a number'); + return; + } + } + + return this.append('$transactions', _.extend({ + '$amount': amount + }, properties), callback); + }; + + /* + * Permanently clear all revenue report transactions from the + * current user's people analytics profile. + * + * ### Usage: + * + * mixpanel.people.clear_charges(); + * + * @param {Function} [callback] If provided, the callback will be called after the tracking event + */ + MixpanelPeople.prototype.clear_charges = function(callback) { + return this.set('$transactions', [], callback); + }; + + /* + * Permanently deletes the current people analytics profile from + * Mixpanel (using the current distinct_id). + * + * ### Usage: + * + * // remove the all data you have stored about the current user + * mixpanel.people.delete_user(); + * + */ + MixpanelPeople.prototype.delete_user = function() { + if (!this._identify_called()) { + console$1.error('mixpanel.people.delete_user() requires you to call identify() first'); + return; + } + var data = {'$delete': this._mixpanel.get_distinct_id()}; + return this._send_request(data); + }; + + MixpanelPeople.prototype.toString = function() { + return this._mixpanel.toString() + '.people'; + }; + + MixpanelPeople.prototype._send_request = function(data, callback) { + data['$token'] = this._get_config('token'); + data['$distinct_id'] = this._mixpanel.get_distinct_id(); + + var date_encoded_data = _.encodeDates(data); + var truncated_data = _.truncate(date_encoded_data, 255); + var json_data = _.JSONEncode(date_encoded_data); + var encoded_data = _.base64Encode(json_data); + + if (!this._identify_called()) { + this._enqueue(data); + if (!_.isUndefined(callback)) { + if (this._get_config('verbose')) { + callback({status: -1, error: null}); + } else { + callback(-1); + } + } + return truncated_data; + } + + console$1.log('MIXPANEL PEOPLE REQUEST:'); + console$1.log(truncated_data); + + this._mixpanel._send_request( + this._get_config('api_host') + '/engage/', + {'data': encoded_data}, + this._mixpanel._prepare_callback(callback, truncated_data) + ); + + return truncated_data; + }; + + MixpanelPeople.prototype._get_config = function(conf_var) { + return this._mixpanel.get_config(conf_var); + }; + + MixpanelPeople.prototype._identify_called = function() { + return this._mixpanel._flags.identify_called === true; + }; + + // Queue up engage operations if identify hasn't been called yet. + MixpanelPeople.prototype._enqueue = function(data) { + if (SET_ACTION in data) { + this._mixpanel['persistence']._add_to_people_queue(SET_ACTION, data); + } else if (SET_ONCE_ACTION in data) { + this._mixpanel['persistence']._add_to_people_queue(SET_ONCE_ACTION, data); + } else if (UNSET_ACTION in data) { + this._mixpanel['persistence']._add_to_people_queue(UNSET_ACTION, data); + } else if (ADD_ACTION in data) { + this._mixpanel['persistence']._add_to_people_queue(ADD_ACTION, data); + } else if (APPEND_ACTION in data) { + this._mixpanel['persistence']._add_to_people_queue(APPEND_ACTION, data); + } else if (UNION_ACTION in data) { + this._mixpanel['persistence']._add_to_people_queue(UNION_ACTION, data); + } else { + console$1.error('Invalid call to _enqueue():', data); + } + }; + + MixpanelPeople.prototype._flush_one_queue = function(action, action_method, callback, queue_to_params_fn) { + var _this = this; + var queued_data = _.extend({}, this._mixpanel['persistence']._get_queue(action)); + var action_params = queued_data; + + if (!_.isUndefined(queued_data) && _.isObject(queued_data) && !_.isEmptyObject(queued_data)) { + _this._mixpanel['persistence']._pop_from_people_queue(action, queued_data); + if (queue_to_params_fn) { + action_params = queue_to_params_fn(queued_data); + } + action_method.call(_this, action_params, function(response, data) { + // on bad response, we want to add it back to the queue + if (response === 0) { + _this._mixpanel['persistence']._add_to_people_queue(action, queued_data); + } + if (!_.isUndefined(callback)) { + callback(response, data); + } + }); + } + }; + + // Flush queued engage operations - order does not matter, + // and there are network level race conditions anyway + MixpanelPeople.prototype._flush = function( + _set_callback, _add_callback, _append_callback, _set_once_callback, _union_callback, _unset_callback + ) { + var _this = this; + var $append_queue = this._mixpanel['persistence']._get_queue(APPEND_ACTION); + + this._flush_one_queue(SET_ACTION, this.set, _set_callback); + this._flush_one_queue(SET_ONCE_ACTION, this.set_once, _set_once_callback); + this._flush_one_queue(UNSET_ACTION, this.unset, _unset_callback, function(queue) { return _.keys(queue); }); + this._flush_one_queue(ADD_ACTION, this.increment, _add_callback); + this._flush_one_queue(UNION_ACTION, this.union, _union_callback); + + // we have to fire off each $append individually since there is + // no concat method server side + if (!_.isUndefined($append_queue) && _.isArray($append_queue) && $append_queue.length) { + var $append_item; + var callback = function(response, data) { + if (response === 0) { + _this._mixpanel['persistence']._add_to_people_queue(APPEND_ACTION, $append_item); + } + if (!_.isUndefined(_append_callback)) { + _append_callback(response, data); + } + }; + for (var i = $append_queue.length - 1; i >= 0; i--) { + $append_item = $append_queue.pop(); + _this.append($append_item, callback); + } + // Save the shortened append queue + _this._mixpanel['persistence'].save(); + } + }; + + MixpanelPeople.prototype._is_reserved_property = function(prop) { + return prop === '$distinct_id' || prop === '$token'; + }; + + + // Internal class for notification display + MixpanelLib._Notification = function(notif_data, mixpanel_instance) { + _.bind_instance_methods(this); + + this.mixpanel = mixpanel_instance; + this.persistence = this.mixpanel['persistence']; + + this.campaign_id = _.escapeHTML(notif_data['id']); + this.message_id = _.escapeHTML(notif_data['message_id']); + + this.body = (_.escapeHTML(notif_data['body']) || '').replace(/\n/g, '
'); + this.cta = _.escapeHTML(notif_data['cta']) || 'Close'; + this.notif_type = _.escapeHTML(notif_data['type']) || 'takeover'; + this.style = _.escapeHTML(notif_data['style']) || 'light'; + this.title = _.escapeHTML(notif_data['title']) || ''; + this.video_width = MPNotif.VIDEO_WIDTH; + this.video_height = MPNotif.VIDEO_HEIGHT; + + // These fields are url-sanitized in the backend already. + this.dest_url = notif_data['cta_url'] || null; + this.image_url = notif_data['image_url'] || null; + this.thumb_image_url = notif_data['thumb_image_url'] || null; + this.video_url = notif_data['video_url'] || null; + + this.clickthrough = true; + if (!this.dest_url) { + this.dest_url = '#dismiss'; + this.clickthrough = false; + } + + this.mini = this.notif_type === 'mini'; + if (!this.mini) { + this.notif_type = 'takeover'; + } + this.notif_width = !this.mini ? MPNotif.NOTIF_WIDTH : MPNotif.NOTIF_WIDTH_MINI; + + this._set_client_config(); + this.imgs_to_preload = this._init_image_html(); + this._init_video(); + }; + + MPNotif = MixpanelLib._Notification; + + MPNotif.ANIM_TIME = 200; + MPNotif.MARKUP_PREFIX = 'mixpanel-notification'; + MPNotif.BG_OPACITY = 0.6; + MPNotif.NOTIF_TOP = 25; + MPNotif.NOTIF_START_TOP = 200; + MPNotif.NOTIF_WIDTH = 388; + MPNotif.NOTIF_WIDTH_MINI = 420; + MPNotif.NOTIF_HEIGHT_MINI = 85; + MPNotif.THUMB_BORDER_SIZE = 5; + MPNotif.THUMB_IMG_SIZE = 60; + MPNotif.THUMB_OFFSET = Math.round(MPNotif.THUMB_IMG_SIZE / 2); + MPNotif.VIDEO_WIDTH = 595; + MPNotif.VIDEO_HEIGHT = 334; + + MPNotif.prototype.show = function() { + var self = this; + this._set_client_config(); + + // don't display until HTML body exists + if (!this.body_el) { + setTimeout(function() { self.show(); }, 300); + return; + } + + this._init_styles(); + this._init_notification_el(); + + // wait for any images to load before showing notification + this._preload_images(this._attach_and_animate); + }; + + MPNotif.prototype.dismiss = _.safewrap(function() { + if (!this.marked_as_shown) { + // unexpected condition: user interacted with notif even though we didn't consider it + // visible (see _mark_as_shown()); send tracking signals to mark delivery + this._mark_delivery({'invisible': true}); + } + + var exiting_el = this.showing_video ? this._get_el('video') : this._get_notification_display_el(); + if (this.use_transitions) { + this._remove_class('bg', 'visible'); + this._add_class(exiting_el, 'exiting'); + setTimeout(this._remove_notification_el, MPNotif.ANIM_TIME); + } else { + var notif_attr, notif_start, notif_goal; + if (this.mini) { + notif_attr = 'right'; + notif_start = 20; + notif_goal = -100; + } else { + notif_attr = 'top'; + notif_start = MPNotif.NOTIF_TOP; + notif_goal = MPNotif.NOTIF_START_TOP + MPNotif.NOTIF_TOP; + } + this._animate_els([ + { + el: this._get_el('bg'), + attr: 'opacity', + start: MPNotif.BG_OPACITY, + goal: 0.0 + }, + { + el: exiting_el, + attr: 'opacity', + start: 1.0, + goal: 0.0 + }, + { + el: exiting_el, + attr: notif_attr, + start: notif_start, + goal: notif_goal + } + ], MPNotif.ANIM_TIME, this._remove_notification_el); + } + }); + + MPNotif.prototype._add_class = _.safewrap(function(el, class_name) { + class_name = MPNotif.MARKUP_PREFIX + '-' + class_name; + if (typeof el === 'string') { + el = this._get_el(el); + } + if (!el.className) { + el.className = class_name; + } else if (!~(' ' + el.className + ' ').indexOf(' ' + class_name + ' ')) { + el.className += ' ' + class_name; + } + }); + MPNotif.prototype._remove_class = _.safewrap(function(el, class_name) { + class_name = MPNotif.MARKUP_PREFIX + '-' + class_name; + if (typeof el === 'string') { + el = this._get_el(el); + } + if (el.className) { + el.className = (' ' + el.className + ' ') + .replace(' ' + class_name + ' ', '') + .replace(/^[\s\xA0]+/, '') + .replace(/[\s\xA0]+$/, ''); + } + }); + + MPNotif.prototype._animate_els = _.safewrap(function(anims, mss, done_cb, start_time) { + var self = this, + in_progress = false, + ai, anim, + cur_time = 1 * new Date(), time_diff; + + start_time = start_time || cur_time; + time_diff = cur_time - start_time; + + for (ai = 0; ai < anims.length; ai++) { + anim = anims[ai]; + if (typeof anim.val === 'undefined') { + anim.val = anim.start; + } + if (anim.val !== anim.goal) { + in_progress = true; + var anim_diff = anim.goal - anim.start, + anim_dir = anim.goal >= anim.start ? 1 : -1; + anim.val = anim.start + anim_diff * time_diff / mss; + if (anim.attr !== 'opacity') { + anim.val = Math.round(anim.val); + } + if ((anim_dir > 0 && anim.val >= anim.goal) || (anim_dir < 0 && anim.val <= anim.goal)) { + anim.val = anim.goal; + } + } + } + if (!in_progress) { + if (done_cb) { + done_cb(); + } + return; + } + + for (ai = 0; ai < anims.length; ai++) { + anim = anims[ai]; + if (anim.el) { + var suffix = anim.attr === 'opacity' ? '' : 'px'; + anim.el.style[anim.attr] = String(anim.val) + suffix; + } + } + setTimeout(function() { self._animate_els(anims, mss, done_cb, start_time); }, 10); + }); + + MPNotif.prototype._attach_and_animate = _.safewrap(function() { + var self = this; + + // no possibility to double-display + if (this.shown || this._get_shown_campaigns()[this.campaign_id]) { + return; + } + this.shown = true; + + this.body_el.appendChild(this.notification_el); + setTimeout(function() { + var notif_el = self._get_notification_display_el(); + if (self.use_transitions) { + if (!self.mini) { + self._add_class('bg', 'visible'); + } + self._add_class(notif_el, 'visible'); + self._mark_as_shown(); + } else { + var notif_attr, notif_start, notif_goal; + if (self.mini) { + notif_attr = 'right'; + notif_start = -100; + notif_goal = 20; + } else { + notif_attr = 'top'; + notif_start = MPNotif.NOTIF_START_TOP + MPNotif.NOTIF_TOP; + notif_goal = MPNotif.NOTIF_TOP; + } + self._animate_els([ + { + el: self._get_el('bg'), + attr: 'opacity', + start: 0.0, + goal: MPNotif.BG_OPACITY + }, + { + el: notif_el, + attr: 'opacity', + start: 0.0, + goal: 1.0 + }, + { + el: notif_el, + attr: notif_attr, + start: notif_start, + goal: notif_goal + } + ], MPNotif.ANIM_TIME, self._mark_as_shown); + } + }, 100); + _.register_event(self._get_el('cancel'), 'click', function(e) { + e.preventDefault(); + self.dismiss(); + }); + var click_el = self._get_el('button') || + self._get_el('mini-content'); + _.register_event(click_el, 'click', function(e) { + e.preventDefault(); + if (self.show_video) { + self._track_event('$campaign_open', {'$resource_type': 'video'}); + self._switch_to_video(); + } else { + self.dismiss(); + if (self.clickthrough) { + self._track_event('$campaign_open', {'$resource_type': 'link'}, function() { + win.location.href = self.dest_url; + }); + } + } + }); + }); + + MPNotif.prototype._get_el = function(id) { + return document$1.getElementById(MPNotif.MARKUP_PREFIX + '-' + id); + }; + + MPNotif.prototype._get_notification_display_el = function() { + return this._get_el(this.notif_type); + }; + + MPNotif.prototype._get_shown_campaigns = function() { + return this.persistence['props'][CAMPAIGN_IDS_KEY] || (this.persistence['props'][CAMPAIGN_IDS_KEY] = {}); + }; + + MPNotif.prototype._browser_lte = function(browser, version) { + return this.browser_versions[browser] && this.browser_versions[browser] <= version; + }; + + MPNotif.prototype._init_image_html = function() { + var imgs_to_preload = []; + + if (!this.mini) { + if (this.image_url) { + imgs_to_preload.push(this.image_url); + this.img_html = ''; + } else { + this.img_html = ''; + } + if (this.thumb_image_url) { + imgs_to_preload.push(this.thumb_image_url); + this.thumb_img_html = + '
' + + '' + + '
'; + } else { + this.thumb_img_html = ''; + } + } else { + this.thumb_image_url = this.thumb_image_url || '//cdn.mxpnl.com/site_media/images/icons/notifications/mini-news-dark.png'; + imgs_to_preload.push(this.thumb_image_url); + } + + return imgs_to_preload; + }; + + MPNotif.prototype._init_notification_el = function() { + var notification_html = ''; + var video_src = ''; + var video_html = ''; + var cancel_html = '
' + + '
' + + '
'; + + this.notification_el = document$1.createElement('div'); + this.notification_el.id = MPNotif.MARKUP_PREFIX + '-wrapper'; + if (!this.mini) { + // TAKEOVER notification + var close_html = (this.clickthrough || this.show_video) ? '' : '
', + play_html = this.show_video ? '
' : ''; + if (this._browser_lte('ie', 7)) { + close_html = ''; + play_html = ''; + } + notification_html = + '
' + + this.thumb_img_html + + '
' + + cancel_html + + '
' + + this.img_html + + '
' + this.title + '
' + + '
' + this.body + '
' + + '
' + + 'POWERED BY MIXPANEL' + + '
' + + '
' + + '
' + + close_html + + '' + this.cta + '' + + play_html + + '
' + + '
' + + '
'; + } else { + // MINI notification + notification_html = + '
' + + '
' + + cancel_html + + '
' + + '
' + + '
' + + '
' + + '
' + + '
' + this.body + '
' + + '
' + + '
' + + '
' + + '
' + + '
'; + } + if (this.youtube_video) { + video_src = '//www.youtube.com/embed/' + this.youtube_video + + '?wmode=transparent&showinfo=0&modestbranding=0&rel=0&autoplay=1&loop=0&vq=hd1080'; + if (this.yt_custom) { + video_src += '&enablejsapi=1&html5=1&controls=0'; + video_html = + '
' + + '
' + + '
' + + '
' + + '
' + + '
' + + '
'; + } + } else if (this.vimeo_video) { + video_src = '//player.vimeo.com/video/' + this.vimeo_video + '?autoplay=1&title=0&byline=0&portrait=0'; + } + if (this.show_video) { + this.video_iframe = + ''; + video_html = + '
' + + '
' + + '
' + + video_html + + '
' + + '
'; + } + var main_html = video_html + notification_html; + if (this.flip_animate) { + main_html = + (this.mini ? notification_html : '') + + '
' + + (this.mini ? video_html : main_html) + + '
'; + } + + this.notification_el.innerHTML = + ('
' + + '
' + + '
' + + '
' + + main_html + + '
' + + '
' + + '
') + .replace(/class=\"/g, 'class="' + MPNotif.MARKUP_PREFIX + '-') + .replace(/id=\"/g, 'id="' + MPNotif.MARKUP_PREFIX + '-'); + }; + + MPNotif.prototype._init_styles = function() { + if (this.style === 'dark') { + this.style_vals = { + bg: '#1d1f25', + bg_actions: '#282b32', + bg_hover: '#3a4147', + bg_light: '#4a5157', + border_gray: '#32353c', + cancel_opacity: '0.4', + mini_hover: '#2a3137', + text_title: '#fff', + text_main: '#9498a3', + text_tagline: '#464851', + text_hover: '#ddd' + }; + } else { + this.style_vals = { + bg: '#fff', + bg_actions: '#e7eaee', + bg_hover: '#eceff3', + bg_light: '#f5f5f5', + border_gray: '#e4ecf2', + cancel_opacity: '1.0', + mini_hover: '#fafafa', + text_title: '#5c6578', + text_main: '#8b949b', + text_tagline: '#ced9e6', + text_hover: '#7c8598' + }; + } + var shadow = '0px 0px 35px 0px rgba(45, 49, 56, 0.7)', + video_shadow = shadow, + mini_shadow = shadow, + thumb_total_size = MPNotif.THUMB_IMG_SIZE + MPNotif.THUMB_BORDER_SIZE * 2, + anim_seconds = (MPNotif.ANIM_TIME / 1000) + 's'; + if (this.mini) { + shadow = 'none'; + } + + // don't display on small viewports + var notif_media_queries = {}, + min_width = MPNotif.NOTIF_WIDTH_MINI + 20; + notif_media_queries['@media only screen and (max-width: ' + (min_width - 1) + 'px)'] = { + '#overlay': { + 'display': 'none' + } + }; + var notif_styles = { + '.flipped': { + 'transform': 'rotateY(180deg)' + }, + '#overlay': { + 'position': 'fixed', + 'top': '0', + 'left': '0', + 'width': '100%', + 'height': '100%', + 'overflow': 'auto', + 'text-align': 'center', + 'z-index': '10000', + 'font-family': '"Helvetica", "Arial", sans-serif', + '-webkit-font-smoothing': 'antialiased', + '-moz-osx-font-smoothing': 'grayscale' + }, + '#overlay.mini': { + 'height': '0', + 'overflow': 'visible' + }, + '#overlay a': { + 'width': 'initial', + 'padding': '0', + 'text-decoration': 'none', + 'text-transform': 'none', + 'color': 'inherit' + }, + '#bgwrapper': { + 'position': 'relative', + 'width': '100%', + 'height': '100%' + }, + '#bg': { + 'position': 'fixed', + 'top': '0', + 'left': '0', + 'width': '100%', + 'height': '100%', + 'min-width': this.doc_width * 4 + 'px', + 'min-height': this.doc_height * 4 + 'px', + 'background-color': 'black', + 'opacity': '0.0', + '-ms-filter': 'progid:DXImageTransform.Microsoft.Alpha(Opacity=60)', // IE8 + 'filter': 'alpha(opacity=60)', // IE5-7 + 'transition': 'opacity ' + anim_seconds + }, + '#bg.visible': { + 'opacity': MPNotif.BG_OPACITY + }, + '.mini #bg': { + 'width': '0', + 'height': '0', + 'min-width': '0' + }, + '#flipcontainer': { + 'perspective': '1000px', + 'position': 'absolute', + 'width': '100%' + }, + '#flipper': { + 'position': 'relative', + 'transform-style': 'preserve-3d', + 'transition': '0.3s' + }, + '#takeover': { + 'position': 'absolute', + 'left': '50%', + 'width': MPNotif.NOTIF_WIDTH + 'px', + 'margin-left': Math.round(-MPNotif.NOTIF_WIDTH / 2) + 'px', + 'backface-visibility': 'hidden', + 'transform': 'rotateY(0deg)', + 'opacity': '0.0', + 'top': MPNotif.NOTIF_START_TOP + 'px', + 'transition': 'opacity ' + anim_seconds + ', top ' + anim_seconds + }, + '#takeover.visible': { + 'opacity': '1.0', + 'top': MPNotif.NOTIF_TOP + 'px' + }, + '#takeover.exiting': { + 'opacity': '0.0', + 'top': MPNotif.NOTIF_START_TOP + 'px' + }, + '#thumbspacer': { + 'height': MPNotif.THUMB_OFFSET + 'px' + }, + '#thumbborder-wrapper': { + 'position': 'absolute', + 'top': (-MPNotif.THUMB_BORDER_SIZE) + 'px', + 'left': (MPNotif.NOTIF_WIDTH / 2 - MPNotif.THUMB_OFFSET - MPNotif.THUMB_BORDER_SIZE) + 'px', + 'width': thumb_total_size + 'px', + 'height': (thumb_total_size / 2) + 'px', + 'overflow': 'hidden' + }, + '#thumbborder': { + 'position': 'absolute', + 'width': thumb_total_size + 'px', + 'height': thumb_total_size + 'px', + 'border-radius': thumb_total_size + 'px', + 'background-color': this.style_vals.bg_actions, + 'opacity': '0.5' + }, + '#thumbnail': { + 'position': 'absolute', + 'top': '0px', + 'left': (MPNotif.NOTIF_WIDTH / 2 - MPNotif.THUMB_OFFSET) + 'px', + 'width': MPNotif.THUMB_IMG_SIZE + 'px', + 'height': MPNotif.THUMB_IMG_SIZE + 'px', + 'overflow': 'hidden', + 'z-index': '100', + 'border-radius': MPNotif.THUMB_IMG_SIZE + 'px' + }, + '#mini': { + 'position': 'absolute', + 'right': '20px', + 'top': MPNotif.NOTIF_TOP + 'px', + 'width': this.notif_width + 'px', + 'height': MPNotif.NOTIF_HEIGHT_MINI * 2 + 'px', + 'margin-top': 20 - MPNotif.NOTIF_HEIGHT_MINI + 'px', + 'backface-visibility': 'hidden', + 'opacity': '0.0', + 'transform': 'rotateX(90deg)', + 'transition': 'opacity 0.3s, transform 0.3s, right 0.3s' + }, + '#mini.visible': { + 'opacity': '1.0', + 'transform': 'rotateX(0deg)' + }, + '#mini.exiting': { + 'opacity': '0.0', + 'right': '-150px' + }, + '#mainbox': { + 'border-radius': '4px', + 'box-shadow': shadow, + 'text-align': 'center', + 'background-color': this.style_vals.bg, + 'font-size': '14px', + 'color': this.style_vals.text_main + }, + '#mini #mainbox': { + 'height': MPNotif.NOTIF_HEIGHT_MINI + 'px', + 'margin-top': MPNotif.NOTIF_HEIGHT_MINI + 'px', + 'border-radius': '3px', + 'transition': 'background-color ' + anim_seconds + }, + '#mini-border': { + 'height': (MPNotif.NOTIF_HEIGHT_MINI + 6) + 'px', + 'width': (MPNotif.NOTIF_WIDTH_MINI + 6) + 'px', + 'position': 'absolute', + 'top': '-3px', + 'left': '-3px', + 'margin-top': MPNotif.NOTIF_HEIGHT_MINI + 'px', + 'border-radius': '6px', + 'opacity': '0.25', + 'background-color': '#fff', + 'z-index': '-1', + 'box-shadow': mini_shadow + }, + '#mini-icon': { + 'position': 'relative', + 'display': 'inline-block', + 'width': '75px', + 'height': MPNotif.NOTIF_HEIGHT_MINI + 'px', + 'border-radius': '3px 0 0 3px', + 'background-color': this.style_vals.bg_actions, + 'background': 'linear-gradient(135deg, ' + this.style_vals.bg_light + ' 0%, ' + this.style_vals.bg_actions + ' 100%)', + 'transition': 'background-color ' + anim_seconds + }, + '#mini:hover #mini-icon': { + 'background-color': this.style_vals.mini_hover + }, + '#mini:hover #mainbox': { + 'background-color': this.style_vals.mini_hover + }, + '#mini-icon-img': { + 'position': 'absolute', + 'background-image': 'url(' + this.thumb_image_url + ')', + 'width': '48px', + 'height': '48px', + 'top': '20px', + 'left': '12px' + }, + '#content': { + 'padding': '30px 20px 0px 20px' + }, + '#mini-content': { + 'text-align': 'left', + 'height': MPNotif.NOTIF_HEIGHT_MINI + 'px', + 'cursor': 'pointer' + }, + '#img': { + 'width': '328px', + 'margin-top': '30px', + 'border-radius': '5px' + }, + '#title': { + 'max-height': '600px', + 'overflow': 'hidden', + 'word-wrap': 'break-word', + 'padding': '25px 0px 20px 0px', + 'font-size': '19px', + 'font-weight': 'bold', + 'color': this.style_vals.text_title + }, + '#body': { + 'max-height': '600px', + 'margin-bottom': '25px', + 'overflow': 'hidden', + 'word-wrap': 'break-word', + 'line-height': '21px', + 'font-size': '15px', + 'font-weight': 'normal', + 'text-align': 'left' + }, + '#mini #body': { + 'display': 'inline-block', + 'max-width': '250px', + 'margin': '0 0 0 30px', + 'height': MPNotif.NOTIF_HEIGHT_MINI + 'px', + 'font-size': '16px', + 'letter-spacing': '0.8px', + 'color': this.style_vals.text_title + }, + '#mini #body-text': { + 'display': 'table', + 'height': MPNotif.NOTIF_HEIGHT_MINI + 'px' + }, + '#mini #body-text div': { + 'display': 'table-cell', + 'vertical-align': 'middle' + }, + '#tagline': { + 'margin-bottom': '15px', + 'font-size': '10px', + 'font-weight': '600', + 'letter-spacing': '0.8px', + 'color': '#ccd7e0', + 'text-align': 'left' + }, + '#tagline a': { + 'color': this.style_vals.text_tagline, + 'transition': 'color ' + anim_seconds + }, + '#tagline a:hover': { + 'color': this.style_vals.text_hover + }, + '#cancel': { + 'position': 'absolute', + 'right': '0', + 'width': '8px', + 'height': '8px', + 'padding': '10px', + 'border-radius': '20px', + 'margin': '12px 12px 0 0', + 'box-sizing': 'content-box', + 'cursor': 'pointer', + 'transition': 'background-color ' + anim_seconds + }, + '#mini #cancel': { + 'margin': '7px 7px 0 0' + }, + '#cancel-icon': { + 'width': '8px', + 'height': '8px', + 'overflow': 'hidden', + 'background-image': 'url(//cdn.mxpnl.com/site_media/images/icons/notifications/cancel-x.png)', + 'opacity': this.style_vals.cancel_opacity + }, + '#cancel:hover': { + 'background-color': this.style_vals.bg_hover + }, + '#button': { + 'display': 'block', + 'height': '60px', + 'line-height': '60px', + 'text-align': 'center', + 'background-color': this.style_vals.bg_actions, + 'border-radius': '0 0 4px 4px', + 'overflow': 'hidden', + 'cursor': 'pointer', + 'transition': 'background-color ' + anim_seconds + }, + '#button-close': { + 'display': 'inline-block', + 'width': '9px', + 'height': '60px', + 'margin-right': '8px', + 'vertical-align': 'top', + 'background-image': 'url(//cdn.mxpnl.com/site_media/images/icons/notifications/close-x-' + this.style + '.png)', + 'background-repeat': 'no-repeat', + 'background-position': '0px 25px' + }, + '#button-play': { + 'display': 'inline-block', + 'width': '30px', + 'height': '60px', + 'margin-left': '15px', + 'background-image': 'url(//cdn.mxpnl.com/site_media/images/icons/notifications/play-' + this.style + '-small.png)', + 'background-repeat': 'no-repeat', + 'background-position': '0px 15px' + }, + 'a#button-link': { + 'display': 'inline-block', + 'vertical-align': 'top', + 'text-align': 'center', + 'font-size': '17px', + 'font-weight': 'bold', + 'overflow': 'hidden', + 'word-wrap': 'break-word', + 'color': this.style_vals.text_title, + 'transition': 'color ' + anim_seconds + }, + '#button:hover': { + 'background-color': this.style_vals.bg_hover, + 'color': this.style_vals.text_hover + }, + '#button:hover a': { + 'color': this.style_vals.text_hover + }, + + '#video-noflip': { + 'position': 'relative', + 'top': (-this.video_height * 2) + 'px' + }, + '#video-flip': { + 'backface-visibility': 'hidden', + 'transform': 'rotateY(180deg)' + }, + '#video': { + 'position': 'absolute', + 'width': (this.video_width - 1) + 'px', + 'height': this.video_height + 'px', + 'top': MPNotif.NOTIF_TOP + 'px', + 'margin-top': '100px', + 'left': '50%', + 'margin-left': Math.round(-this.video_width / 2) + 'px', + 'overflow': 'hidden', + 'border-radius': '5px', + 'box-shadow': video_shadow, + 'transform': 'translateZ(1px)', // webkit rendering bug http://stackoverflow.com/questions/18167981/clickable-link-area-unexpectedly-smaller-after-css-transform + 'transition': 'opacity ' + anim_seconds + ', top ' + anim_seconds + }, + '#video.exiting': { + 'opacity': '0.0', + 'top': this.video_height + 'px' + }, + '#video-holder': { + 'position': 'absolute', + 'width': (this.video_width - 1) + 'px', + 'height': this.video_height + 'px', + 'overflow': 'hidden', + 'border-radius': '5px' + }, + '#video-frame': { + 'margin-left': '-1px', + 'width': this.video_width + 'px' + }, + '#video-controls': { + 'opacity': '0', + 'transition': 'opacity 0.5s' + }, + '#video:hover #video-controls': { + 'opacity': '1.0' + }, + '#video .video-progress-el': { + 'position': 'absolute', + 'bottom': '0', + 'height': '25px', + 'border-radius': '0 0 0 5px' + }, + '#video-progress': { + 'width': '90%' + }, + '#video-progress-total': { + 'width': '100%', + 'background-color': this.style_vals.bg, + 'opacity': '0.7' + }, + '#video-elapsed': { + 'width': '0', + 'background-color': '#6cb6f5', + 'opacity': '0.9' + }, + '#video #video-time': { + 'width': '10%', + 'right': '0', + 'font-size': '11px', + 'line-height': '25px', + 'color': this.style_vals.text_main, + 'background-color': '#666', + 'border-radius': '0 0 5px 0' + } + }; + + // IE hacks + if (this._browser_lte('ie', 8)) { + _.extend(notif_styles, { + '* html #overlay': { + 'position': 'absolute' + }, + '* html #bg': { + 'position': 'absolute' + }, + 'html, body': { + 'height': '100%' + } + }); + } + if (this._browser_lte('ie', 7)) { + _.extend(notif_styles, { + '#mini #body': { + 'display': 'inline', + 'zoom': '1', + 'border': '1px solid ' + this.style_vals.bg_hover + }, + '#mini #body-text': { + 'padding': '20px' + }, + '#mini #mini-icon': { + 'display': 'none' + } + }); + } + + // add vendor-prefixed style rules + var VENDOR_STYLES = ['backface-visibility', 'border-radius', 'box-shadow', 'opacity', + 'perspective', 'transform', 'transform-style', 'transition'], + VENDOR_PREFIXES = ['khtml', 'moz', 'ms', 'o', 'webkit']; + for (var selector in notif_styles) { + for (var si = 0; si < VENDOR_STYLES.length; si++) { + var prop = VENDOR_STYLES[si]; + if (prop in notif_styles[selector]) { + var val = notif_styles[selector][prop]; + for (var pi = 0; pi < VENDOR_PREFIXES.length; pi++) { + notif_styles[selector]['-' + VENDOR_PREFIXES[pi] + '-' + prop] = val; + } + } + } + } + + var inject_styles = function(styles, media_queries) { + var create_style_text = function(style_defs) { + var st = ''; + for (var selector in style_defs) { + var mp_selector = selector + .replace(/#/g, '#' + MPNotif.MARKUP_PREFIX + '-') + .replace(/\./g, '.' + MPNotif.MARKUP_PREFIX + '-'); + st += '\n' + mp_selector + ' {'; + var props = style_defs[selector]; + for (var k in props) { + st += k + ':' + props[k] + ';'; + } + st += '}'; + } + return st; + }; + var create_media_query_text = function(mq_defs) { + var mqt = ''; + for (var mq in mq_defs) { + mqt += '\n' + mq + ' {' + create_style_text(mq_defs[mq]) + '\n}'; + } + return mqt; + }; + + var style_text = create_style_text(styles) + create_media_query_text(media_queries), + head_el = document$1.head || document$1.getElementsByTagName('head')[0] || document$1.documentElement, + style_el = document$1.createElement('style'); + head_el.appendChild(style_el); + style_el.setAttribute('type', 'text/css'); + if (style_el.styleSheet) { // IE + style_el.styleSheet.cssText = style_text; + } else { + style_el.textContent = style_text; + } + }; + inject_styles(notif_styles, notif_media_queries); + }; + + MPNotif.prototype._init_video = _.safewrap(function() { + if (!this.video_url) { + return; + } + var self = this; + + // Youtube iframe API compatibility + self.yt_custom = 'postMessage' in win; + + self.dest_url = self.video_url; + var youtube_match = self.video_url.match( + // http://stackoverflow.com/questions/2936467/parse-youtube-video-id-using-preg-match + /(?:youtube(?:-nocookie)?\.com\/(?:[^\/]+\/.+\/|(?:v|e(?:mbed)?)\/|.*[?&]v=)|youtu\.be\/)([^"&?\/ ]{11})/i + ), + vimeo_match = self.video_url.match( + /vimeo\.com\/.*?(\d+)/i + ); + if (youtube_match) { + self.show_video = true; + self.youtube_video = youtube_match[1]; + + if (self.yt_custom) { + win['onYouTubeIframeAPIReady'] = function() { + if (self._get_el('video-frame')) { + self._yt_video_ready(); + } + }; + + // load Youtube iframe API; see https://developers.google.com/youtube/iframe_api_reference + var tag = document$1.createElement('script'); + tag.src = '//www.youtube.com/iframe_api'; + var firstScriptTag = document$1.getElementsByTagName('script')[0]; + firstScriptTag.parentNode.insertBefore(tag, firstScriptTag); + } + } else if (vimeo_match) { + self.show_video = true; + self.vimeo_video = vimeo_match[1]; + } + + // IE <= 7, FF <= 3: fall through to video link rather than embedded player + if (self._browser_lte('ie', 7) || self._browser_lte('firefox', 3)) { + self.show_video = false; + self.clickthrough = true; + } + }); + + MPNotif.prototype._mark_as_shown = _.safewrap(function() { + // click on background to dismiss + var self = this; + _.register_event(self._get_el('bg'), 'click', function() { + self.dismiss(); + }); + + var get_style = function(el, style_name) { + var styles = {}; + if (document$1.defaultView && document$1.defaultView.getComputedStyle) { + styles = document$1.defaultView.getComputedStyle(el, null); // FF3 requires both args + } else if (el.currentStyle) { // IE + styles = el.currentStyle; + } + return styles[style_name]; + }; + + if (this.campaign_id) { + var notif_el = this._get_el('overlay'); + if (notif_el && get_style(notif_el, 'visibility') !== 'hidden' && get_style(notif_el, 'display') !== 'none') { + this._mark_delivery(); + } + } + }); + + MPNotif.prototype._mark_delivery = _.safewrap(function(extra_props) { + if (!this.marked_as_shown) { + this.marked_as_shown = true; + + if (this.campaign_id) { + // mark notification shown (local cache) + this._get_shown_campaigns()[this.campaign_id] = 1 * new Date(); + this.persistence.save(); + } + + // track delivery + this._track_event('$campaign_delivery', extra_props); + + // mark notification shown (mixpanel property) + this.mixpanel['people']['append']({ + '$campaigns': this.campaign_id, + '$notifications': { + 'campaign_id': this.campaign_id, + 'message_id': this.message_id, + 'type': 'web', + 'time': new Date() + } + }); + } + }); + + MPNotif.prototype._preload_images = function(all_loaded_cb) { + var self = this; + if (this.imgs_to_preload.length === 0) { + all_loaded_cb(); + return; + } + + var preloaded_imgs = 0; + var img_objs = []; + var onload = function() { + preloaded_imgs++; + if (preloaded_imgs === self.imgs_to_preload.length && all_loaded_cb) { + all_loaded_cb(); + all_loaded_cb = null; + } + }; + for (var i = 0; i < this.imgs_to_preload.length; i++) { + var img = new Image(); + img.onload = onload; + img.src = this.imgs_to_preload[i]; + if (img.complete) { + onload(); + } + img_objs.push(img); + } + + // IE6/7 doesn't fire onload reliably + if (this._browser_lte('ie', 7)) { + setTimeout(function() { + var imgs_loaded = true; + for (i = 0; i < img_objs.length; i++) { + if (!img_objs[i].complete) { + imgs_loaded = false; + } + } + if (imgs_loaded && all_loaded_cb) { + all_loaded_cb(); + all_loaded_cb = null; + } + }, 500); + } + }; + + MPNotif.prototype._remove_notification_el = _.safewrap(function() { + win.clearInterval(this._video_progress_checker); + this.notification_el.style.visibility = 'hidden'; + this.body_el.removeChild(this.notification_el); + }); + + MPNotif.prototype._set_client_config = function() { + var get_browser_version = function(browser_ex) { + var match = navigator.userAgent.match(browser_ex); + return match && match[1]; + }; + this.browser_versions = {}; + this.browser_versions['chrome'] = get_browser_version(/Chrome\/(\d+)/); + this.browser_versions['firefox'] = get_browser_version(/Firefox\/(\d+)/); + this.browser_versions['ie'] = get_browser_version(/MSIE (\d+).+/); + if (!this.browser_versions['ie'] && !(win.ActiveXObject) && 'ActiveXObject' in win) { + this.browser_versions['ie'] = 11; + } + + this.body_el = document$1.body || document$1.getElementsByTagName('body')[0]; + if (this.body_el) { + this.doc_width = Math.max( + this.body_el.scrollWidth, document$1.documentElement.scrollWidth, + this.body_el.offsetWidth, document$1.documentElement.offsetWidth, + this.body_el.clientWidth, document$1.documentElement.clientWidth + ); + this.doc_height = Math.max( + this.body_el.scrollHeight, document$1.documentElement.scrollHeight, + this.body_el.offsetHeight, document$1.documentElement.offsetHeight, + this.body_el.clientHeight, document$1.documentElement.clientHeight + ); + } + + // detect CSS compatibility + var ie_ver = this.browser_versions['ie']; + var sample_styles = document$1.createElement('div').style, + is_css_compatible = function(rule) { + if (rule in sample_styles) { + return true; + } + if (!ie_ver) { + rule = rule[0].toUpperCase() + rule.slice(1); + var props = ['O' + rule, 'Webkit' + rule, 'Moz' + rule]; + for (var i = 0; i < props.length; i++) { + if (props[i] in sample_styles) { + return true; + } + } + } + return false; + }; + this.use_transitions = this.body_el && + is_css_compatible('transition') && + is_css_compatible('transform'); + this.flip_animate = (this.browser_versions['chrome'] >= 33 || this.browser_versions['firefox'] >= 15) && + this.body_el && + is_css_compatible('backfaceVisibility') && + is_css_compatible('perspective') && + is_css_compatible('transform'); + }; + + MPNotif.prototype._switch_to_video = _.safewrap(function() { + var self = this, + anims = [ + { + el: self._get_notification_display_el(), + attr: 'opacity', + start: 1.0, + goal: 0.0 + }, + { + el: self._get_notification_display_el(), + attr: 'top', + start: MPNotif.NOTIF_TOP, + goal: -500 + }, + { + el: self._get_el('video-noflip'), + attr: 'opacity', + start: 0.0, + goal: 1.0 + }, + { + el: self._get_el('video-noflip'), + attr: 'top', + start: -self.video_height * 2, + goal: 0 + } + ]; + + if (self.mini) { + var bg = self._get_el('bg'), + overlay = self._get_el('overlay'); + bg.style.width = '100%'; + bg.style.height = '100%'; + overlay.style.width = '100%'; + + self._add_class(self._get_notification_display_el(), 'exiting'); + self._add_class(bg, 'visible'); + + anims.push({ + el: self._get_el('bg'), + attr: 'opacity', + start: 0.0, + goal: MPNotif.BG_OPACITY + }); + } + + var video_el = self._get_el('video-holder'); + video_el.innerHTML = self.video_iframe; + + var video_ready = function() { + if (win['YT'] && win['YT']['loaded']) { + self._yt_video_ready(); + } + self.showing_video = true; + self._get_notification_display_el().style.visibility = 'hidden'; + }; + if (self.flip_animate) { + self._add_class('flipper', 'flipped'); + setTimeout(video_ready, MPNotif.ANIM_TIME); + } else { + self._animate_els(anims, MPNotif.ANIM_TIME, video_ready); + } + }); + + MPNotif.prototype._track_event = function(event_name, properties, cb) { + if (this.campaign_id) { + properties = properties || {}; + properties = _.extend(properties, { + 'campaign_id': this.campaign_id, + 'message_id': this.message_id, + 'message_type': 'web_inapp', + 'message_subtype': this.notif_type + }); + this.mixpanel['track'](event_name, properties, cb); + } else if (cb) { + cb.call(); + } + }; + + MPNotif.prototype._yt_video_ready = _.safewrap(function() { + var self = this; + if (self.video_inited) { + return; + } + self.video_inited = true; + + var progress_bar = self._get_el('video-elapsed'), + progress_time = self._get_el('video-time'), + progress_el = self._get_el('video-progress'); + + new win['YT']['Player'](MPNotif.MARKUP_PREFIX + '-video-frame', { + 'events': { + 'onReady': function(event) { + var ytplayer = event['target'], + video_duration = ytplayer['getDuration'](), + pad = function(i) { + return ('00' + i).slice(-2); + }, + update_video_time = function(current_time) { + var secs = Math.round(video_duration - current_time), + mins = Math.floor(secs / 60), + hours = Math.floor(mins / 60); + secs -= mins * 60; + mins -= hours * 60; + progress_time.innerHTML = '-' + (hours ? hours + ':' : '') + pad(mins) + ':' + pad(secs); + }; + update_video_time(0); + self._video_progress_checker = win.setInterval(function() { + var current_time = ytplayer['getCurrentTime'](); + progress_bar.style.width = (current_time / video_duration * 100) + '%'; + update_video_time(current_time); + }, 250); + _.register_event(progress_el, 'click', function(e) { + var clickx = Math.max(0, e.pageX - progress_el.getBoundingClientRect().left); + ytplayer['seekTo'](video_duration * clickx / progress_el.clientWidth, true); + }); + } + } + }); + }); + + // EXPORTS (for closure compiler) + + // MixpanelLib Exports + MixpanelLib.prototype['init'] = MixpanelLib.prototype.init; + MixpanelLib.prototype['reset'] = MixpanelLib.prototype.reset; + MixpanelLib.prototype['disable'] = MixpanelLib.prototype.disable; + MixpanelLib.prototype['time_event'] = MixpanelLib.prototype.time_event; + MixpanelLib.prototype['track'] = MixpanelLib.prototype.track; + MixpanelLib.prototype['track_links'] = MixpanelLib.prototype.track_links; + MixpanelLib.prototype['track_forms'] = MixpanelLib.prototype.track_forms; + MixpanelLib.prototype['track_pageview'] = MixpanelLib.prototype.track_pageview; + MixpanelLib.prototype['register'] = MixpanelLib.prototype.register; + MixpanelLib.prototype['register_once'] = MixpanelLib.prototype.register_once; + MixpanelLib.prototype['unregister'] = MixpanelLib.prototype.unregister; + MixpanelLib.prototype['identify'] = MixpanelLib.prototype.identify; + MixpanelLib.prototype['alias'] = MixpanelLib.prototype.alias; + MixpanelLib.prototype['name_tag'] = MixpanelLib.prototype.name_tag; + MixpanelLib.prototype['set_config'] = MixpanelLib.prototype.set_config; + MixpanelLib.prototype['get_config'] = MixpanelLib.prototype.get_config; + MixpanelLib.prototype['get_property'] = MixpanelLib.prototype.get_property; + MixpanelLib.prototype['get_distinct_id'] = MixpanelLib.prototype.get_distinct_id; + MixpanelLib.prototype['toString'] = MixpanelLib.prototype.toString; + MixpanelLib.prototype['_check_and_handle_notifications'] = MixpanelLib.prototype._check_and_handle_notifications; + MixpanelLib.prototype['_show_notification'] = MixpanelLib.prototype._show_notification; + + // MixpanelPersistence Exports + MixpanelPersistence.prototype['properties'] = MixpanelPersistence.prototype.properties; + MixpanelPersistence.prototype['update_search_keyword'] = MixpanelPersistence.prototype.update_search_keyword; + MixpanelPersistence.prototype['update_referrer_info'] = MixpanelPersistence.prototype.update_referrer_info; + MixpanelPersistence.prototype['get_cross_subdomain'] = MixpanelPersistence.prototype.get_cross_subdomain; + MixpanelPersistence.prototype['clear'] = MixpanelPersistence.prototype.clear; + + // MixpanelPeople Exports + MixpanelPeople.prototype['set'] = MixpanelPeople.prototype.set; + MixpanelPeople.prototype['set_once'] = MixpanelPeople.prototype.set_once; + MixpanelPeople.prototype['unset'] = MixpanelPeople.prototype.unset; + MixpanelPeople.prototype['increment'] = MixpanelPeople.prototype.increment; + MixpanelPeople.prototype['append'] = MixpanelPeople.prototype.append; + MixpanelPeople.prototype['union'] = MixpanelPeople.prototype.union; + MixpanelPeople.prototype['track_charge'] = MixpanelPeople.prototype.track_charge; + MixpanelPeople.prototype['clear_charges'] = MixpanelPeople.prototype.clear_charges; + MixpanelPeople.prototype['delete_user'] = MixpanelPeople.prototype.delete_user; + MixpanelPeople.prototype['toString'] = MixpanelPeople.prototype.toString; + + _.safewrap_class(MixpanelLib, ['identify', '_check_and_handle_notifications', '_show_notification']); + + var instances = {}; + var extend_mp = function() { + // add all the sub mixpanel instances + _.each(instances, function(instance, name) { + if (name !== PRIMARY_INSTANCE_NAME) { mixpanel_master[name] = instance; } + }); + + // add private functions as _ + mixpanel_master['_'] = _; + }; + + var override_mp_init_func = function() { + // we override the snippets init function to handle the case where a + // user initializes the mixpanel library after the script loads & runs + mixpanel_master['init'] = function(token, config, name) { + if (name) { + // initialize a sub library + if (!mixpanel_master[name]) { + mixpanel_master[name] = instances[name] = create_mplib(token, config, name); + mixpanel_master[name]._loaded(); + } + return mixpanel_master[name]; + } else { + var instance = mixpanel_master; + + if (instances[PRIMARY_INSTANCE_NAME]) { + // main mixpanel lib already initialized + instance = instances[PRIMARY_INSTANCE_NAME]; + } else if (token) { + // intialize the main mixpanel lib + instance = create_mplib(token, config, PRIMARY_INSTANCE_NAME); + instance._loaded(); + instances[PRIMARY_INSTANCE_NAME] = instance; + } + + mixpanel_master = instance; + if (init_type === INIT_SNIPPET) { + win[PRIMARY_INSTANCE_NAME] = mixpanel_master; + } + extend_mp(); + } + }; + }; + + var add_dom_loaded_handler = function() { + // Cross browser DOM Loaded support + function dom_loaded_handler() { + // function flag since we only want to execute this once + if (dom_loaded_handler.done) { return; } + dom_loaded_handler.done = true; + + DOM_LOADED = true; + ENQUEUE_REQUESTS = false; + + _.each(instances, function(inst) { + inst._dom_loaded(); + }); + } + + function do_scroll_check() { + try { + document$1.documentElement.doScroll('left'); + } catch(e) { + setTimeout(do_scroll_check, 1); + return; + } + + dom_loaded_handler(); + } + + if (document$1.addEventListener) { + if (document$1.readyState === 'complete') { + // safari 4 can fire the DOMContentLoaded event before loading all + // external JS (including this file). you will see some copypasta + // on the internet that checks for 'complete' and 'loaded', but + // 'loaded' is an IE thing + dom_loaded_handler(); + } else { + document$1.addEventListener('DOMContentLoaded', dom_loaded_handler, false); + } + } else if (document$1.attachEvent) { + // IE + document$1.attachEvent('onreadystatechange', dom_loaded_handler); + + // check to make sure we arn't in a frame + var toplevel = false; + try { + toplevel = win.frameElement === null; + } catch(e) { + // noop + } + + if (document$1.documentElement.doScroll && toplevel) { + do_scroll_check(); + } + } + + // fallback handler, always will work + _.register_event(win, 'load', dom_loaded_handler, true); + }; + + var add_dom_event_counting_handlers = function(instance) { + var name = instance.get_config('name'); + + instance.mp_counts = instance.mp_counts || {}; + instance.mp_counts['$__c'] = parseInt(_.cookie.get('mp_' + name + '__c')) || 0; + + var increment_count = function() { + instance.mp_counts['$__c'] = (instance.mp_counts['$__c'] || 0) + 1; + _.cookie.set('mp_' + name + '__c', instance.mp_counts['$__c'], 1, true); + }; + + var evtCallback = function() { + try { + instance.mp_counts = instance.mp_counts || {}; + increment_count(); + } catch (e) { + console$1.error(e); + } + }; + _.register_event(document$1, 'submit', evtCallback); + _.register_event(document$1, 'change', evtCallback); + var mousedownTarget = null; + _.register_event(document$1, 'mousedown', function(e) { + mousedownTarget = e.target; + }); + _.register_event(document$1, 'mouseup', function(e) { + if (e.target === mousedownTarget) { + evtCallback(e); + } + }); + }; + + function init_from_snippet() { + init_type = INIT_SNIPPET; + mixpanel_master = win[PRIMARY_INSTANCE_NAME]; + + // Initialization + if (_.isUndefined(mixpanel_master)) { + // mixpanel wasn't initialized properly, report error and quit + console$1.critical('"mixpanel" object not initialized. Ensure you are using the latest version of the Mixpanel JS Library along with the snippet we provide.'); + return; + } + if (mixpanel_master['__loaded'] || (mixpanel_master['config'] && mixpanel_master['persistence'])) { + // lib has already been loaded at least once; we don't want to override the global object this time so bomb early + console$1.error('Mixpanel library has already been downloaded at least once.'); + return; + } + var snippet_version = mixpanel_master['__SV'] || 0; + if (snippet_version < 1.1) { + // mixpanel wasn't initialized properly, report error and quit + console$1.critical('Version mismatch; please ensure you\'re using the latest version of the Mixpanel code snippet.'); + return; + } + + // Load instances of the Mixpanel Library + _.each(mixpanel_master['_i'], function(item) { + if (item && _.isArray(item)) { + instances[item[item.length-1]] = create_mplib.apply(this, item); + } + }); + + override_mp_init_func(); + mixpanel_master['init'](); + + // Fire loaded events after updating the window's mixpanel object + _.each(instances, function(instance) { + instance._loaded(); + }); + + add_dom_loaded_handler(); + } + + init_from_snippet(); + +}()); diff --git a/web/src/sass/_common.sass b/web/src/sass/_common.sass index de962138..f924962a 100755 --- a/web/src/sass/_common.sass +++ b/web/src/sass/_common.sass @@ -24,7 +24,7 @@ =fl font-family: 'ProximaNova-Light', serif -$pink: #FF9393 +$pink: #FF9393linear-gradient(-225deg, #FFE2EB 0%, #D8F5F5 100%) $pink-light: #FDF8F9 $purple: #B995D9 $green: #8ECFC0 @@ -37,14 +37,14 @@ $border: #E6E6E6 $cl: #191919 body - min-width: 360px + min-width: 360px +f font-size: 15px - color: $cl + color: $cl line-height: 1.333 +t font-size: 14px - line-height: 1.57 + line-height: 1.57 &.no-scroll overflow: hidden button, @@ -54,9 +54,9 @@ select +f font-size: 15px button - background: none + background: none a - color: $pink + color: $pink text-decoration: none input, textarea @@ -68,11 +68,11 @@ textarea strong, b +fb - + * &::selection background: $pink - color: white + color: white // box-shadow: 0 0 0 1px red button @@ -81,7 +81,7 @@ button .outer display: flex - min-height: 100vh + min-height: 100vh flex-direction: column .container @@ -96,11 +96,11 @@ button +t padding: 0 15px &_md - max-width: 860px + max-width: 860px &_sm - max-width: 700px + max-width: 700px &_xs - max-width: 540px + max-width: 540px .btn position: relative @@ -114,9 +114,9 @@ button letter-spacing: 2px text-align: center transition: all .2s - z-index: 2 + z-index: 2 +t - line-height: 1.33 + line-height: 1.33 &:hover box-shadow: 0 5px 20px 0 rgba(0,0,0,0.11) &:disabled @@ -136,7 +136,7 @@ button .icon position: relative top: -1px - margin-right: 15px + margin-right: 15px fill: $cl transition: fill .2s &_dark @@ -145,14 +145,14 @@ button &:hover background: #545454 &:active - color: rgba(white,.1) + color: rgba(white,.1) &_light background-image: linear-gradient(-225deg, #FFE2EB 0%, #D8F5F5 100%) &_stroke &:hover, &:active &:before - opacity: 0 + opacity: 0 &:before content: '' position: absolute @@ -162,12 +162,12 @@ button bottom: 1px background: white border-radius: 2px - transition: opacity .2s + transition: opacity .2s z-index: -2 &_gray - background: $bg + background: $bg &_md - padding: 18px 24px 17px + padding: 18px 24px 17px +m padding: 13px 24px 12px &_lg @@ -181,13 +181,13 @@ button &_icon padding-left: 9px padding-right: 9px - background: transparent + background: transparent border: 1px solid $border font-size: 0 .icon margin: 0 font-size: 13px - fill: $cl + fill: $cl &.loading box-shadow: none color: transparent @@ -203,7 +203,7 @@ button margin: -12px 0 0 -12px border: 3px solid $cl border-left: 3px solid transparent - border-radius: 50% + border-radius: 50% animation: loading .6s infinite linear &_fb background: #3957A7 @@ -250,7 +250,7 @@ a.btn background: url(../img/school-lil-city.svg) no-repeat 0 0 / contain +t width: 124px - height: 40px + height: 40px .search position: relative @@ -267,8 +267,8 @@ a.btn padding: 0 15px background: rgba($cl,.1) border-radius: 20px - color: rgba($cl,.8) - transition: width .4s + color: rgba($cl,.8) + transition: width .4s +m width: 100% &__btn @@ -278,7 +278,7 @@ a.btn bottom: 0 width: 40px .icon - font-size: 17px + font-size: 17px fill: rgba($cl,.5) transform: translate(0,2px) &.open &__input @@ -296,7 +296,7 @@ a.btn &__container display: flex height: 100px - border-bottom: 1px solid $border + border-bottom: 1px solid $border align-items: center +t height: 65px @@ -318,9 +318,9 @@ a.btn bottom: 0 background: white flex-direction: column - z-index: 20 + z-index: 20 &.visible - display: block + display: block &__actions display: flex margin: auto @@ -332,7 +332,7 @@ a.btn left: 0 right: 0 padding: 10px 15px - background: white + background: white &__status width: 100px margin-right: 5px @@ -346,7 +346,7 @@ a.btn +m display: flex height: 70px - border-bottom: 1px solid $border + border-bottom: 1px solid $border align-items: center &__search +m @@ -357,29 +357,29 @@ a.btn margin: auto +m display: block - height: calc(100vh - 71px) + height: calc(100vh - 71px) margin: 0 padding: 15px 0 overflow: auto &__group position: relative &__group:hover &__section - color: $cl + color: $cl +m - color: $pink + color: $pink &__group:hover &__list - opacity: 1 + opacity: 1 visibility: visible &__section display: block height: 100px margin: 0 15px border-bottom: 1px solid transparent - +fb + +fb font-size: 12px color: #8C8C8C line-height: 100px - transition: color .2s + transition: color .2s +t height: 65px font-size: 10px @@ -391,17 +391,17 @@ a.btn padding: 15px 40px 15px 20px border: none font-size: 14px - color: $pink - letter-spacing: 2.5px + color: $pink + letter-spacing: 2.5px line-height: 1.33 &_sub +m - &:before, + &:before, &:after content: '' position: absolute top: 50% - background: $gray + background: $gray transform: translateY(-50%) &:before right: 20px @@ -410,7 +410,7 @@ a.btn &:after right: 25px width: 2px - height: 12px + height: 12px &.active border-color: $cl color: $cl @@ -419,7 +419,7 @@ a.btn &.open +m &:after - opacity: 0 + opacity: 0 &__list, &__drop position: absolute @@ -433,7 +433,7 @@ a.btn border-radius: 2px opacity: 0 visibility: hidden - transform: translate3d(0,0,0) + transform: translate3d(0,0,0) transition: opacity .2s, visibility .2s overflow: hidden &__list @@ -460,7 +460,7 @@ a.btn &__balance margin-bottom: 10px padding: 0 20px 10px - border-bottom: 1px solid $border + border-bottom: 1px solid $border &__link display: block position: relative @@ -479,7 +479,7 @@ a.btn &_green color: $green-light +m - color: $green-light + color: $green-light &:hover color: darken($green-light,10) &_gray @@ -489,7 +489,7 @@ a.btn &_border margin-bottom: 5px padding: 2px 20px 10px - border-bottom: 1px solid $border + border-bottom: 1px solid $border color: $cl &__link.active &__title &:after @@ -529,19 +529,19 @@ a.btn +fb font-size: 12px color: #8C8C8C - line-height: 100px - transition: color .2s + line-height: 100px + transition: color .2s +t height: 65px margin-left: 25px font-size: 10px - line-height: 65px + line-height: 65px +m margin: 0 font-size: 0 - line-height: 1 + line-height: 1 &:hover - color: $cl + color: $cl .icon display: none +m @@ -560,7 +560,7 @@ a.btn padding: 15px 20px &__login:hover &__drop opacity: 1 - visibility: visible + visibility: visible &__ava height: 40px &_bg &__container @@ -573,15 +573,15 @@ a.btn margin-top: -100px padding: 120px 0 50px background-position: 50% 50% - background-repeat: no-repeat - background-size: cover + background-repeat: no-repeat + background-size: cover text-align: center - z-index: 4 + z-index: 4 +t - min-height: 350px - padding: 137px 0 40px + min-height: 350px + padding: 137px 0 40px +m - min-height: 400px + min-height: 400px &:before content: '' position: absolute @@ -590,20 +590,20 @@ a.btn right: 0 bottom: 0 background-image: linear-gradient(-225deg, #FFE2EB 0%, #D8F5F5 100%) - opacity: .8 + opacity: .8 z-index: -2 &__center margin: auto &__title max-width: 780px - margin: 0 auto 40px + margin: 0 auto 40px +fb font-size: 50px - line-height: (6/5) + line-height: (6/5) +t - max-width: 400px + max-width: 400px font-size: 24px - line-height: 1.33 + line-height: 1.33 +m margin-bottom: 50px &__content @@ -618,12 +618,12 @@ a.btn &:before background-image: linear-gradient(-225deg, #E2E2E2 0%, #E2FFDF 100%) &_sm - min-height: 0 + min-height: 0 padding: 135px 0 55px +t min-height: 0 +m - min-height: 0 + min-height: 0 &_sm &__title margin-bottom: 0 @@ -646,7 +646,7 @@ a[name] text-align: center &_sm margin-bottom: 20px - font-size: 25px + font-size: 25px .subtitle margin-bottom: 25px @@ -671,7 +671,7 @@ a[name] text-align: center z-index: 2 +t - max-width: 400px + max-width: 400px margin-bottom: 40px font-size: 14px line-height: 1.5 @@ -741,7 +741,7 @@ a[name] +m padding: 30px 0 &_review - background: url(../img/bg-elephants.jpg) 0 0 / 100px 102px + background: url(../img/bg-elephants.jpg) 0 0 / 100px 102px &_gray background: $bg &_pink-light @@ -756,10 +756,10 @@ a[name] width: 100% max-width: 944px height: 1px - background: $border + background: $border transform: translateX(-50%) +t - max-width: calc(100% - 30px) + max-width: calc(100% - 30px) &_gradient background-image: linear-gradient(-225deg, #F8F8F8 0%, #FFF2F2 100%) &_tabs @@ -778,7 +778,9 @@ a[name] &__pic display: block width: 100% + height: 100% border-radius: 50% + object-fit: cover &__input position: absolute top: 0 @@ -834,7 +836,7 @@ a[name] align-items: center &__ava height: 40px - margin-right: 10px + margin-right: 10px flex: 0 0 40px &__ava height: 40px @@ -862,7 +864,7 @@ a[name] font-size: 15px &_white &__date - color: white + color: white .video display: block @@ -885,9 +887,9 @@ a[name] top: 0 left: 0 width: 100% - height: 100% + height: 100% padding: 20px - background: rgba($cl,.5) + background: rgba($cl,.5) align-items: center justify-content: center flex-direction: column @@ -928,7 +930,7 @@ a[name] +t max-width: calc(100% - 55px) +m - max-width: 100% + max-width: 100% &__more text-align: center +t @@ -939,10 +941,10 @@ a[name] position: relative padding: 55px 0 50px background-position: 50% 50% - background-repeat: no-repeat + background-repeat: no-repeat background-size: cover color: white - text-align: center + text-align: center z-index: 4 +t padding: 30px 0 @@ -961,11 +963,11 @@ a[name] margin-bottom: 85px +fb font-size: 20px - letter-spacing: 4px + letter-spacing: 4px +t margin-bottom: 30px font-size: 12px - letter-spacing: 2px + letter-spacing: 2px +m margin-bottom: 40px &__title @@ -981,7 +983,7 @@ a[name] +t margin-bottom: 65px +m - max-width: 80% + max-width: 80% margin-bottom: 40px &__action +fb @@ -1001,7 +1003,7 @@ a[name] text-align: center flex-wrap: wrap justify-content: center - z-index: 4 + z-index: 4 +t margin: 0 +m @@ -1034,17 +1036,17 @@ a[name] font-size: 37px &__title display: flex - min-height: 38px + min-height: 38px margin-bottom: 10px +fb font-size: 14px letter-spacing: 2.5px - line-height: 1.42 + line-height: 1.42 text-transform: uppercase align-items: center justify-content: center +m - min-height: 0 + min-height: 0 &__content +t font-size: 12px @@ -1063,7 +1065,7 @@ a[name] margin-bottom: 40px &__ava height: 60px - margin-right: 20px + margin-right: 20px flex: 0 0 60px +t height: 45px @@ -1080,7 +1082,7 @@ a[name] &__name +fb font-size: 15px - line-height: 1.33 + line-height: 1.33 .gallery display: flex @@ -1116,11 +1118,11 @@ a[name] margin: 0 10px 20px +m width: calc(33.33% - 10px) - margin: 0 5px 10px + margin: 0 5px 10px &_lg width: calc(66.66% - 20px) +m - width: calc(66.66% - 10px) + width: calc(66.66% - 10px) &:nth-child(4) clear: both &__pic @@ -1144,7 +1146,7 @@ a[name] padding: 50px 0 60px flex: 0 0 49% text-align: center - z-index: 2 + z-index: 2 +t padding: 30px 0 35px flex: 0 0 47% @@ -1201,9 +1203,9 @@ a[name] &__pic display: block max-width: 100% - max-height: 277px + max-height: 277px +m - max-height: 185px + max-height: 185px a.grey-link color: #A7A7A7 @@ -1225,7 +1227,7 @@ a.grey-link &__wrap max-width: 375px +t - max-width: 220px + max-width: 220px +m max-width: 180px &__theme @@ -1243,28 +1245,28 @@ a.grey-link margin-bottom: 30px +fs font-size: 18px - line-height: 1.33 + line-height: 1.33 +m margin-bottom: 15px font-size: 14px &__btn - background: transparent + background: transparent padding: 11px 24px 10px border: 2px solid $cl - color: $cl + color: $cl &:hover - background: $cl + background: $cl border-color: $cl color: white &:active - color: rgba(white,.1) + color: rgba(white,.1) &__preview position: absolute top: 0 left: calc(50% - 145px) bottom: 0 +t - left: calc(50% - 110px) + left: calc(50% - 110px) +m left: 50% &__pic @@ -1299,31 +1301,31 @@ a.grey-link letter-spacing: 4px +t font-size: 12px - letter-spacing: 2px + letter-spacing: 2px &__name margin-bottom: 20px +fb &__content font-size: 16px - line-height: (22/16) + line-height: (22/16) .toggle font-size: 14px &__head - color: $pink + color: $pink .icon position: relative top: -1px margin-left: 10px font-size: 6px - transition: transform .2s + transition: transform .2s &.active .icon transform: rotate(-180deg) &__body display: none padding-top: 10px - color: #888 + color: #888 .schedule margin-bottom: 70px @@ -1378,7 +1380,7 @@ a.grey-link &__item display: block margin: 0 10px 60px - color: $cl + color: $cl flex: 0 0 calc(33.33% - 20px) +t margin-bottom: 40px @@ -1388,7 +1390,7 @@ a.grey-link display: block position: relative margin-bottom: 15px - color: $cl + color: $cl +t margin-bottom: 10px &__preview:hover &__view @@ -1407,13 +1409,13 @@ a.grey-link height: 30px right: 10px &_fav - background-image: url(../img/fav.svg) + background-image: url(../img/fav.svg) &_clock - background-image: url(../img/clock.svg) + background-image: url(../img/clock.svg) &__pic display: block width: 100% - border-radius: 2px + border-radius: 2px &__view display: flex position: absolute @@ -1422,13 +1424,13 @@ a.grey-link width: 100% height: 100% background-image: linear-gradient(-225deg, #FFE2EB 0%, #D8F5F5 100%) - border-radius: 2px + border-radius: 2px opacity: 0 font-size: 18px align-items: center justify-content: center transition: opacity .2s - z-index: 4 + z-index: 4 &__soon display: flex position: absolute @@ -1438,7 +1440,7 @@ a.grey-link bottom: 0 padding: 10px background: rgba(black,.4) - color: white + color: white flex-direction: column justify-content: center text-align: center @@ -1454,7 +1456,7 @@ a.grey-link letter-spacing: 3px +t font-size: 12px - letter-spacing: 1px + letter-spacing: 1px &__details display: flex margin-bottom: 10px @@ -1463,13 +1465,13 @@ a.grey-link +fb font-size: 12px letter-spacing: 2px - color: $cl + color: $cl &__title display: block margin-bottom: 10px +fs font-size: 18px - color: $cl + color: $cl +t line-height: 1.33 &__user @@ -1512,14 +1514,14 @@ a.grey-link margin-top: 30px &__btn position: relative - min-width: 300px + min-width: 300px padding: 19px 24px 18px - border-radius: 28px - letter-spacing: 2px + border-radius: 28px + letter-spacing: 2px z-index: 4 &:hover &:before - opacity: 0 + opacity: 0 &:before content: '' position: absolute @@ -1528,9 +1530,9 @@ a.grey-link right: 5px bottom: 5px background: white - border-radius: 24px - transition: opacity .2s - z-index: -2 + border-radius: 24px + transition: opacity .2s + z-index: -2 .partners display: flex @@ -1554,7 +1556,7 @@ a.grey-link display: block position: relative font-size: 13px - user-select: none + user-select: none cursor: pointer &__input position: absolute @@ -1563,7 +1565,7 @@ a.grey-link opacity: 0 &__input:checked + &__content &:after - opacity: 1 + opacity: 1 &__content display: block padding: 3px 0 3px 30px @@ -1612,18 +1614,18 @@ a.grey-link text-align: right &_blue &__content &:after - background: #4A90E2 + background: #4A90E2 &_lesson &__content display: flex padding: 19px 0 19px 50px border-bottom: 1px solid $border font-size: 16px - color: $gray + color: $gray transition: color .2s +t padding: 10px 0 10px 40px flex-wrap: wrap - line-height: 1.33 + line-height: 1.33 &:before, &:after transform: translateY(18px) @@ -1636,7 +1638,7 @@ a.grey-link &_circle &__content &:before, &:after - border-radius: 50% + border-radius: 50% &_lg &__content padding: 0 0 0 30px font-size: 16px @@ -1646,7 +1648,7 @@ a.grey-link .footer padding: 50px 0 30px - background: $bg + background: $bg +t padding: 30px 0 35px &__row @@ -1718,9 +1720,9 @@ a.grey-link max-width: 200px font-size: 18px +t - max-width: 250px + max-width: 250px font-size: 14px - line-height: 1.33 + line-height: 1.33 &__title padding: 20px 0 50px +fb @@ -1729,12 +1731,12 @@ a.grey-link text-transform: uppercase +t padding: 0 0 20px - letter-spacing: 2px + letter-spacing: 2px &__link, &__contact display: table font-size: 14px - color: $cl + color: $cl line-height: (20/14) +t padding: 5px 0 @@ -1756,7 +1758,7 @@ a.grey-link +t display: none &__links &__link - color: #888 + color: #888 &__divider margin: 0 10px @@ -1784,7 +1786,7 @@ a.grey-link &:not(:last-child) margin-right: 20px .icon - &-instagram + &-instagram font-size: 18px &-twitter font-size: 17px @@ -1795,8 +1797,8 @@ a.grey-link .select position: relative - user-select: none - z-index: 4 + user-select: none + z-index: 4 &__head position: relative height: 36px @@ -1806,7 +1808,7 @@ a.grey-link line-height: 36px white-space: nowrap text-overflow: ellipsis - color: $gray + color: $gray cursor: pointer transition: border-color .2s padding-right: 15px @@ -1816,12 +1818,12 @@ a.grey-link position: absolute top: 14px right: 0 - +arr(8,8,$cl,b) + +arr(8,8,$cl,b) &__drop position: absolute left: 0 right: 0 - top: calc(100% + 10px) + top: calc(100% + 10px) padding: 10px 0 background: white box-shadow: 0 2px 20px 0 rgba(0,0,0,0.10) @@ -1844,7 +1846,7 @@ a.grey-link +m padding: 11px 20px font-size: 12px - color: $cl + color: $cl &:hover color: $cl &__option.active @@ -1863,7 +1865,7 @@ a.grey-link display: table position: relative &.active - z-index: 99 + z-index: 99 &.active &__head border-color: $cl &:after @@ -1872,7 +1874,7 @@ a.grey-link opacity: 1 visibility: visible &.selected &__head - color: $cl + color: $cl .error.info__field--light .select @@ -1904,14 +1906,14 @@ a.grey-link &_gray color: $gray &__link - margin-left: auto + margin-left: auto &__wrap position: relative &__input, &__textarea width: 100% border-bottom: 1px solid $border - background: transparent + background: transparent font-size: 18px transition: border-color .2s &:focus @@ -1929,7 +1931,7 @@ a.grey-link &_bg height: 50px padding: 0 20px - &__textarea + &__textarea display: block height: auto padding: 7px 0 @@ -1962,7 +1964,7 @@ a.grey-link &_info &__input, &_info &__textarea border-color: rgba($border,.2) - color: white + color: white &:focus border-color: white &.error &__input, @@ -1982,7 +1984,7 @@ a.grey-link margin-top: 11px .field__wrap &--title - margin-right: 25px + margin-right: 60px &--additional margin-top: 10px svg.icon-password-eye @@ -2012,14 +2014,14 @@ a.grey-link +fb font-size: 12px letter-spacing: 2px - line-height: 60px - color: $gray + line-height: 60px + color: $gray text-transform: uppercase text-align: center flex: 1 0 50% - transition: border-color .2s, color .2s + transition: border-color .2s, color .2s &.active - border-color: $cl + border-color: $cl color: $cl &__tab display: none @@ -2037,12 +2039,12 @@ a.grey-link font-size: 12px color: $cl text-decoration: underline - line-height: 1.5 + line-height: 1.5 &__foot margin-top: 30px &__btn width: 100% - padding: 18px 24px 17px + padding: 18px 24px 17px &__or padding: 10px 0 font-size: 14px @@ -2062,7 +2064,7 @@ a.grey-link left: 0 right: 0 bottom: 0 - min-width: 360px + min-width: 360px padding: 15px background: rgba($cl,.7) opacity: 0 @@ -2077,12 +2079,12 @@ a.grey-link background: white box-shadow: 0 2px 20px rgba(0,0,0,0.10) border-radius: 3px - transform: scale(.9) + transform: scale(.9) transition: transform .2s &_md - max-width: 620px + max-width: 620px &_lg - max-width: 1000px + max-width: 1000px &__close position: absolute top: -15px @@ -2090,18 +2092,18 @@ a.grey-link padding: 10px background: $cl border-radius: 50% - box-shadow: 0 2px 20px rgba(0,0,0,0.10) + box-shadow: 0 2px 20px rgba(0,0,0,0.10) font-size: 0 .icon font-size: 14px fill: white &.open - display: flex + display: flex &.visible opacity: 1 &.visible &__wrap - transform: scale(1) - + transform: scale(1) + .head display: flex margin-bottom: 50px @@ -2121,7 +2123,7 @@ a.grey-link margin: 0 padding: 0 20px 0 260px flex: 0 0 calc(100% - 220px) - z-index: 4 + z-index: 4 +t margin: 0 0 15px padding: 0 @@ -2132,7 +2134,7 @@ a.grey-link left: calc(50% - 60px) transform: translateY(-50%) z-index: -2 - + .layout display: flex margin: 0 -10px @@ -2170,7 +2172,7 @@ a.grey-link &__pic display: block width: 100% - border-radius: 2px + border-radius: 2px &__wrap flex: 0 0 calc(100% - 95px) &__title @@ -2189,9 +2191,9 @@ a.grey-link margin-bottom: 30px &__item display: flex - max-width: calc(50% - 20px) + max-width: calc(50% - 20px) align-items: center - color: $cl + color: $cl &__arrow margin-right: 15px font-size: 0 @@ -2204,7 +2206,7 @@ a.grey-link font-size: 12px &__title font-size: 18px - line-height: 1.33 + line-height: 1.33 +t font-size: 14px &__title + &__arrow @@ -2246,7 +2248,7 @@ a.grey-link margin-bottom: 60px &__head display: flex - min-height: 40px + min-height: 40px margin-bottom: 30px align-items: center justify-content: space-between @@ -2275,15 +2277,15 @@ a.grey-link background: rgba(black,.3) &__pic display: block - width: 100% + width: 100% &__btn position: absolute top: 50% left: 50% - min-width: 200px - background: transparent + min-width: 200px + background: transparent border: 2px solid white - color: white + color: white transform: translate(-50%,-50%) overflow: hidden &:active @@ -2298,7 +2300,7 @@ a.grey-link +m display: block &__buy - width: 220px + width: 220px margin-left: auto +m width: auto @@ -2312,7 +2314,7 @@ a.grey-link margin-bottom: 25px &__metas &__meta +m - margin-bottom: 20px + margin-bottom: 20px &__actions display: flex margin: 0 -10px 50px @@ -2339,8 +2341,8 @@ a.grey-link padding: 100px 0 110px background-size: cover background-position: 50% 50% - background-repeat: no-repeat - color: white + background-repeat: no-repeat + color: white z-index: 4 +t padding: 60px 0 @@ -2355,7 +2357,7 @@ a.grey-link left: 0 width: 100% height: 100% - background-image: linear-gradient(0deg, rgba(0,0,0,0.24) 0%, rgba(0,0,0,0.64) 100%) + background-image: linear-gradient(0deg, rgba(0,0,0,0.24) 0%, rgba(0,0,0,0.64) 100%) z-index: -2 &:after left: 50% @@ -2366,7 +2368,7 @@ a.grey-link color: white .icon fill: white - + .lessons &__list margin-bottom: 60px @@ -2375,12 +2377,12 @@ a.grey-link &__item position: relative margin-bottom: 40px - transition: box-shadow .2s + transition: box-shadow .2s +m margin-bottom: 30px &__item:hover &__actions opacity: 1 - visibility: visible + visibility: visible &__subtitle margin-bottom: 20px &__row @@ -2388,7 +2390,7 @@ a.grey-link +m display: block &__preview - margin-right: 25px + margin-right: 25px flex: 0 0 140px +m display: none @@ -2404,7 +2406,7 @@ a.grey-link right: 10px opacity: 0 visibility: hidden - transition: opacity .2s, visibility .2s + transition: opacity .2s, visibility .2s &__action margin-left: 10px padding: 10px @@ -2415,7 +2417,7 @@ a.grey-link .icon font-size: 20px fill: #C8C8C8 - transition: fill .2s + transition: fill .2s &__foot text-align: center &__btn @@ -2425,10 +2427,10 @@ a.grey-link padding: 20px &:hover box-shadow: 0 10px 50px 0 rgba(0,0,0,0.06) - border-radius: 10px + border-radius: 10px &_kit &__content flex: 0 0 100% - + .lesson &__subtitle margin-bottom: 10px @@ -2444,7 +2446,7 @@ a.grey-link &__content margin-bottom: 30px color: #191919 - + .materials &__item display: flex @@ -2485,8 +2487,13 @@ a.grey-link &__pic display: block width: 100% - + .questions + &__anchor + display: block; + position: relative; + top: -110px; + visibility: hidden; &__form, &__item display: flex @@ -2510,7 +2517,7 @@ a.grey-link background: white border-radius: 0 10px 10px 10px +m - padding: 10px + padding: 10px &__ava height: 60px margin-right: 20px @@ -2533,15 +2540,15 @@ a.grey-link width: 100% height: 70px padding: 11px 15px - border-radius: 2px + border-radius: 2px font-size: 16px - resize: vertical + resize: vertical +m height: 64px &__btn display: block margin: 0 auto - border-radius: 20px + border-radius: 20px &__details margin-bottom: 5px &__head, @@ -2556,7 +2563,7 @@ a.grey-link margin-bottom: 5px &__action, &__date - color: $gray + color: $gray &__author margin-right: 15px &__date @@ -2586,7 +2593,7 @@ a.grey-link span:first-child display: none; &_sm &__title - margin-bottom: 15px + margin-bottom: 15px &_sm &__item &:not(:last-child) margin-right: 10px @@ -2622,7 +2629,7 @@ a.grey-link +m flex: 0 0 100% &__head_main - padding-top: 25px + padding-top: 25px +t padding-top: 21px &__label @@ -2653,7 +2660,7 @@ a.grey-link &__wrap padding: 20px 30px 30px background: white - border-radius: 6px + border-radius: 6px +t padding: 15px 20px 20px &__title @@ -2675,7 +2682,7 @@ a.grey-link display: flex margin: 30px -30px -10px padding: 20px 30px 0 - border-top: 1px solid $border + border-top: 1px solid $border +fb font-size: 16px +t @@ -2717,7 +2724,7 @@ a.grey-link padding: 0 &__btn padding: 10px 15px - background: transparent + background: transparent border: 1px solid $gray color: $gray &_edit @@ -2750,12 +2757,12 @@ a.grey-link +fb font-size: 24px letter-spacing: 0 - text-transform: none + text-transform: none &__share margin-bottom: 35px &__content max-width: 760px - margin: 0 auto + margin: 0 auto &__foot display: none margin-top: 30px @@ -2775,7 +2782,7 @@ a.grey-link margin: 0 -15px 30px &__btn height: 56px - border-bottom: 1px solid $border + border-bottom: 1px solid $border +fb font-size: 12px color: $gray @@ -2788,7 +2795,7 @@ a.grey-link +m margin: 0 &:hover - color: $cl + color: $cl &.active border-color: $cl color: $cl @@ -2805,11 +2812,11 @@ a.grey-link margin-right: 30px &.active +fb - color: $cl - + color: $cl + .confirm &__title - margin-bottom: 20px + margin-bottom: 20px font-size: 24px &__form display: flex @@ -2824,7 +2831,7 @@ a.grey-link &__field +m margin-bottom: 20px - + .form position: relative &__ava @@ -2835,7 +2842,7 @@ a.grey-link +t position: relative left: 0 - margin-bottom: 40px + margin-bottom: 40px &__group position: relative margin-bottom: 40px @@ -2917,8 +2924,8 @@ a.grey-link margin-top: 40px &__btn width: 100% - max-width: 300px - + max-width: 300px + .author &__row display: flex @@ -2944,38 +2951,38 @@ a.grey-link font-size: 12px letter-spacing: 2px +m - margin-bottom: 10px + margin-bottom: 10px &__name font-size: 10px letter-spacing: 1.66px &__content - margin-bottom: 30px + margin-bottom: 30px +m margin-bottom: 10px .upload position: relative font-size: 18px - color: $blue + color: $blue overflow: hidden &__file position: absolute top: 0 right: 0 - opacity: 0 + opacity: 0 cursor: pointer .info display: flex - background: $bg + background: $bg +m display: block margin-top: 60px &__section display: flex position: relative - background-position: 50% 50% - background-size: cover + background-position: 50% 50% + background-size: cover flex: 0 0 calc(50% + 169px) justify-content: flex-end z-index: 4 @@ -2999,7 +3006,7 @@ a.grey-link flex-grow: 1 +t width: 100% - max-width: 100% + max-width: 100% padding: 30px 15px 50px &__head display: flex @@ -3024,7 +3031,7 @@ a.grey-link &__upload margin-left: auto font-size: 16px - color: rgba(white,.7) + color: rgba(white,.7) &__foot display: flex margin-top: auto @@ -3035,11 +3042,11 @@ a.grey-link flex: 0 0 140px flex-grow: 1 &__wrap - max-width: 349px + max-width: 349px height: 550px padding: 30px 40px 30px 30px +t - max-width: 100% + max-width: 100% height: auto padding: 30px 15px 30px &__wrap @@ -3067,7 +3074,7 @@ a.grey-link margin-right: 10px padding: 19px border: 1px solid $border - border-radius: 50% + border-radius: 50% .icon font-size: 20px fill: #B5B5B5 @@ -3168,7 +3175,7 @@ a.grey-link top: 0 right: 0 font-size: 100px - opacity: 0 + opacity: 0 cursor: pointer &__fieldset flex: 0 0 calc(100% - 160px) @@ -3200,14 +3207,14 @@ a.grey-link .editor position: relative - + .fontstyle position: absolute top: -40px left: 40px padding: 0 15px background: $cl - border-radius: 35px + border-radius: 35px font-size: 0 white-space: nowrap &__regular, @@ -3216,12 +3223,12 @@ a.grey-link width: 28px height: 35px font-size: 16px - color: white + color: white &__bold +fb &__italic font-style: italic - + .ui-datepicker display: none width: 240px @@ -3229,7 +3236,7 @@ a.grey-link padding: 5px background: white box-shadow: 0 2px 20px 0 rgba(0,0,0,0.10) - z-index: 99!important + z-index: 99!important &-header display: flex margin-bottom: 5px @@ -3245,11 +3252,11 @@ a.grey-link width: 10px height: 10px border: solid $border - border-width: 2px 2px 0 0 + border-width: 2px 2px 0 0 &-prev order: 1 &:before - transform: rotate(-135deg) + transform: rotate(-135deg) &-title text-align: center order: 2 @@ -3272,12 +3279,12 @@ a.grey-link display: block padding: 7px color: #8C8C8C - transition: color .2s + transition: color .2s &:hover - color: $cl + color: $cl &.ui-state-active - background: $bg - color: $pink + background: $bg + color: $pink .form__common-error margin-top: 15px @@ -3298,7 +3305,7 @@ a.grey-link background: $pink &_info background: $green - + .mobile-hide +m @@ -3311,14 +3318,14 @@ a.grey-link height: 40px padding: 0 10px border: 1px solid $border - border-radius: 3px 0 0 3px + border-radius: 3px 0 0 3px font-size: 18px transition: border-color .2s &:focus border-color: $cl flex: 0 0 calc(100% - 100px) &__btn - border-radius: 0 3px 3px 0 + border-radius: 0 3px 3px 0 flex: 0 0 100px a diff --git a/web/src/sass/app.sass b/web/src/sass/app.sass index 9272f307..82cdc94a 100755 --- a/web/src/sass/app.sass +++ b/web/src/sass/app.sass @@ -2,3 +2,4 @@ @import helpers/all @import generated/sprite-svg @import common +@import '~baguettebox.js/dist/baguetteBox.min.css'; \ No newline at end of file diff --git a/web/src/sass/components/notification.scss b/web/src/sass/components/notification.scss new file mode 100644 index 00000000..38773913 --- /dev/null +++ b/web/src/sass/components/notification.scss @@ -0,0 +1,33 @@ +.notification { + min-width: 380px; + box-sizing: border-box; + border-radius: 4px; + border-width: 1px; + border-style: solid; + border-color: #ebeef5; + position: fixed; + left: 50%; + top: 20px; + transform: translateX(-50%); + background-color: #edf2fc; + transition: opacity 0.3s, transform .4s; + overflow: hidden; + padding: 15px 15px 15px 20px; + display: flex; + align-items: center; + z-index: 1000; + + &--success { + background: #53CF86; + color: #fff; + border: none; + box-shadow: 0 4px 15px rgba(0, 196, 83, 0.31); + } + + &--error { + background: #D12424; + color: #fff; + border: none; + box-shadow: 0 4px 15px rgba(255, 36, 36, 0.51); + } +} diff --git a/web/src/sass/generated/_sprite-svg.scss b/web/src/sass/generated/_sprite-svg.scss index 88817d5d..729565d4 100755 --- a/web/src/sass/generated/_sprite-svg.scss +++ b/web/src/sass/generated/_sprite-svg.scss @@ -53,6 +53,11 @@ height: 1em; fill: #1C2635; } +.icon-hamburger { + width: 1.25em; + height: 1em; + fill: #C8C8C8; +} .icon-image-text { width: 2.07em; height: 1em; diff --git a/web/src/templates/partials/_header.pug b/web/src/templates/partials/_header.pug index 569cfa92..67422350 100755 --- a/web/src/templates/partials/_header.pug +++ b/web/src/templates/partials/_header.pug @@ -65,7 +65,7 @@ else a.header__link(href='#') div.header__title Контакты .header__group - a.header__section.header__section_sub.js-header-section(href='#') ВИДЕО-КУРСЫ + a.header__section.header__section_sub.js-header-section(href='#') ВИДЕОКУРСЫ .header__list.js-header-list a.header__link(href='#') .header__title ПЕРСОНАЖ diff --git a/web/src/templates/partials/_popups.pug b/web/src/templates/partials/_popups.pug index 743ad542..3fe4f9af 100755 --- a/web/src/templates/partials/_popups.pug +++ b/web/src/templates/partials/_popups.pug @@ -71,7 +71,7 @@ .buy__row .buy__col .buy__head.buy__head_main - .buy__title Выбор урока/дня + .buy__title Выбор курса/дня .buy__content При записи на 5 уроков скидка 10%. .buy__col .buy__head diff --git a/web/webpack.config.js b/web/webpack.config.js index bb23a500..7b73ecc4 100644 --- a/web/webpack.config.js +++ b/web/webpack.config.js @@ -6,7 +6,8 @@ const ExtractTextPlugin = require("extract-text-webpack-plugin"); module.exports = { entry: { app: "./src/js/app.js", - courseRedactor: "./src/js/course-redactor.js" + courseRedactor: "./src/js/course-redactor.js", + mixpanel: "./src/js/third_party/mixpanel-2-latest.js" }, output: { path: path.join(__dirname, "build"), @@ -20,7 +21,7 @@ module.exports = { loaders: [ { test: /\.js$/, - exclude: /(node_modules|bower_components)/, + exclude: /(node_modules|bower_components|third_party)/, use: { loader: 'babel-loader', options: { @@ -28,6 +29,16 @@ module.exports = { } } }, + { + test: /third_party\/.*\.js$/, + exclude: /(node_modules|bower_components)/, + use: { + loader: 'file-loader', + options: { + name: "[name].[ext]" + } + } + }, { test: /\.css$/, use: ExtractTextPlugin.extract({ @@ -97,4 +108,4 @@ if (NODE_ENV === 'production') { } }) ); -} \ No newline at end of file +} diff --git a/web/yarn.lock b/web/yarn.lock index 06389b2f..b8b74a0f 100644 --- a/web/yarn.lock +++ b/web/yarn.lock @@ -168,7 +168,7 @@ anymatch@^1.3.0: micromatch "^2.1.5" normalize-path "^2.0.0" -aproba@^1.0.3: +aproba@^1.0.3, aproba@^1.1.1: version "1.2.0" resolved "https://registry.yarnpkg.com/aproba/-/aproba-1.2.0.tgz#6802e6264efd18c790a1b0d517f0f2627bf2c94a" @@ -260,7 +260,7 @@ arraybuffer.slice@~0.0.7: version "0.0.7" resolved "https://registry.yarnpkg.com/arraybuffer.slice/-/arraybuffer.slice-0.0.7.tgz#3bbc4275dd584cc1b10809b89d4e8b63a69e7675" -arrify@^1.0.0: +arrify@^1.0.0, arrify@^1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/arrify/-/arrify-1.0.1.tgz#898508da2226f380df904728456849c1501a4b0d" @@ -887,6 +887,10 @@ backo2@1.0.2: version "1.0.2" resolved "https://registry.yarnpkg.com/backo2/-/backo2-1.0.2.tgz#31ab1ac8b129363463e35b3ebb69f4dfcfba7947" +baguettebox.js@^1.10.0: + version "1.10.0" + resolved "https://registry.yarnpkg.com/baguettebox.js/-/baguettebox.js-1.10.0.tgz#f24500b2f02433f52338cf77016ecf00cfe7f974" + balanced-match@^0.4.2: version "0.4.2" resolved "https://registry.yarnpkg.com/balanced-match/-/balanced-match-0.4.2.tgz#cb3f3e3c732dc0f01ee70b403f302e61d7709838" @@ -957,7 +961,7 @@ block-stream@*: dependencies: inherits "~2.0.0" -bluebird@^3.0.5, bluebird@^3.1.1: +bluebird@^3.0.5, bluebird@^3.1.1, bluebird@^3.5.1: version "3.5.1" resolved "https://registry.yarnpkg.com/bluebird/-/bluebird-3.5.1.tgz#d9551f9de98f1fcda1e683d17ee91a0602ee2eb9" @@ -1135,6 +1139,10 @@ bs-recipes@1.3.4: version "1.3.4" resolved "https://registry.yarnpkg.com/bs-recipes/-/bs-recipes-1.3.4.tgz#0d2d4d48a718c8c044769fdc4f89592dc8b69585" +buffer-from@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/buffer-from/-/buffer-from-1.0.0.tgz#4cb8832d23612589b0406e9e2956c17f06fdf531" + buffer-xor@^1.0.3: version "1.0.3" resolved "https://registry.yarnpkg.com/buffer-xor/-/buffer-xor-1.0.3.tgz#26e61ed1422fb70dd42e6e36729ed51d855fe8d9" @@ -1161,6 +1169,24 @@ builtin-status-codes@^3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/builtin-status-codes/-/builtin-status-codes-3.0.0.tgz#85982878e21b98e1c66425e03d0174788f569ee8" +cacache@^10.0.4: + version "10.0.4" + resolved "https://registry.yarnpkg.com/cacache/-/cacache-10.0.4.tgz#6452367999eff9d4188aefd9a14e9d7c6a263460" + dependencies: + bluebird "^3.5.1" + chownr "^1.0.1" + glob "^7.1.2" + graceful-fs "^4.1.11" + lru-cache "^4.1.1" + mississippi "^2.0.0" + mkdirp "^0.5.1" + move-concurrently "^1.0.1" + promise-inflight "^1.0.1" + rimraf "^2.6.2" + ssri "^5.2.4" + unique-filename "^1.1.0" + y18n "^4.0.0" + cache-base@^1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/cache-base/-/cache-base-1.0.1.tgz#0a7f46416831c8b662ee36fe4e7c59d76f666ab2" @@ -1319,6 +1345,10 @@ chokidar@1.7.0, chokidar@^1.7.0: optionalDependencies: fsevents "^1.0.0" +chownr@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/chownr/-/chownr-1.0.1.tgz#e2a75042a9551908bebd25b8523d5f9769d79181" + cipher-base@^1.0.0, cipher-base@^1.0.1, cipher-base@^1.0.3: version "1.0.4" resolved "https://registry.yarnpkg.com/cipher-base/-/cipher-base-1.0.4.tgz#8760e4ecc272f4c363532f926d874aae2c1397de" @@ -1499,6 +1529,15 @@ concat-map@0.0.1: version "0.0.1" resolved "https://registry.yarnpkg.com/concat-map/-/concat-map-0.0.1.tgz#d8a96bd77fd68df7793a73036a3ba0d5405d477b" +concat-stream@^1.5.0: + version "1.6.2" + resolved "https://registry.yarnpkg.com/concat-stream/-/concat-stream-1.6.2.tgz#904bdf194cd3122fc675c77fc4ac3d4ff0fd1a34" + dependencies: + buffer-from "^1.0.0" + inherits "^2.0.3" + readable-stream "^2.2.2" + typedarray "^0.0.6" + config-chain@~1.1.5: version "1.1.11" resolved "https://registry.yarnpkg.com/config-chain/-/config-chain-1.1.11.tgz#aba09747dfbe4c3e70e766a6e41586e1859fc6f2" @@ -1558,10 +1597,34 @@ cookie@0.3.1: version "0.3.1" resolved "https://registry.yarnpkg.com/cookie/-/cookie-0.3.1.tgz#e7e0a1f9ef43b4c8ba925c5c5a96e806d16873bb" +copy-concurrently@^1.0.0: + version "1.0.5" + resolved "https://registry.yarnpkg.com/copy-concurrently/-/copy-concurrently-1.0.5.tgz#92297398cae34937fcafd6ec8139c18051f0b5e0" + dependencies: + aproba "^1.1.1" + fs-write-stream-atomic "^1.0.8" + iferr "^0.1.5" + mkdirp "^0.5.1" + rimraf "^2.5.4" + run-queue "^1.0.0" + copy-descriptor@^0.1.0: version "0.1.1" resolved "https://registry.yarnpkg.com/copy-descriptor/-/copy-descriptor-0.1.1.tgz#676f6eb3c39997c2ee1ac3a924fd6124748f578d" +copy-webpack-plugin@^4.5.1: + version "4.5.1" + resolved "https://registry.yarnpkg.com/copy-webpack-plugin/-/copy-webpack-plugin-4.5.1.tgz#fc4f68f4add837cc5e13d111b20715793225d29c" + dependencies: + cacache "^10.0.4" + find-cache-dir "^1.0.0" + globby "^7.1.1" + is-glob "^4.0.0" + loader-utils "^1.1.0" + minimatch "^3.0.4" + p-limit "^1.0.0" + serialize-javascript "^1.4.0" + core-js@^2.4.0, core-js@^2.5.0: version "2.5.3" resolved "https://registry.yarnpkg.com/core-js/-/core-js-2.5.3.tgz#8acc38345824f16d8365b7c9b4259168e8ed603e" @@ -1766,6 +1829,10 @@ currently-unhandled@^0.4.1: dependencies: array-find-index "^1.0.1" +cyclist@~0.2.2: + version "0.2.2" + resolved "https://registry.yarnpkg.com/cyclist/-/cyclist-0.2.2.tgz#1b33792e11e914a2fd6d6ed6447464444e5fa640" + d@1: version "1.0.0" resolved "https://registry.yarnpkg.com/d/-/d-1.0.0.tgz#754bb5bfe55451da69a58b94d45f4c5b0462d58f" @@ -1942,6 +2009,13 @@ diffie-hellman@^5.0.0: miller-rabin "^4.0.0" randombytes "^2.0.0" +dir-glob@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/dir-glob/-/dir-glob-2.0.0.tgz#0b205d2b6aef98238ca286598a8204d29d0a0034" + dependencies: + arrify "^1.0.1" + path-type "^3.0.0" + doctypes@^1.1.0: version "1.1.0" resolved "https://registry.yarnpkg.com/doctypes/-/doctypes-1.1.0.tgz#ea80b106a87538774e8a3a4a5afe293de489e0a9" @@ -1995,6 +2069,15 @@ duplexer@~0.1.1: version "0.1.1" resolved "https://registry.yarnpkg.com/duplexer/-/duplexer-0.1.1.tgz#ace6ff808c1ce66b57d1ebf97977acb02334cfc1" +duplexify@^3.4.2, duplexify@^3.5.3: + version "3.5.4" + resolved "https://registry.yarnpkg.com/duplexify/-/duplexify-3.5.4.tgz#4bb46c1796eabebeec4ca9a2e66b808cb7a3d8b4" + dependencies: + end-of-stream "^1.0.0" + inherits "^2.0.1" + readable-stream "^2.0.0" + stream-shift "^1.0.0" + duplexify@^3.5.0: version "3.5.3" resolved "https://registry.yarnpkg.com/duplexify/-/duplexify-3.5.3.tgz#8b5818800df92fd0125b27ab896491912858243e" @@ -2064,7 +2147,7 @@ encodeurl@~1.0.1: version "1.0.2" resolved "https://registry.yarnpkg.com/encodeurl/-/encodeurl-1.0.2.tgz#ad3ff4c86ec2d029322f5a02c3a9a606c95b3f59" -end-of-stream@^1.0.0: +end-of-stream@^1.0.0, end-of-stream@^1.1.0: version "1.4.1" resolved "https://registry.yarnpkg.com/end-of-stream/-/end-of-stream-1.4.1.tgz#ed29634d19baba463b6ce6b80a37213eab71ec43" dependencies: @@ -2491,6 +2574,13 @@ flatten@^1.0.2: version "1.0.2" resolved "https://registry.yarnpkg.com/flatten/-/flatten-1.0.2.tgz#dae46a9d78fbe25292258cc1e780a41d95c03782" +flush-write-stream@^1.0.0: + version "1.0.3" + resolved "https://registry.yarnpkg.com/flush-write-stream/-/flush-write-stream-1.0.3.tgz#c5d586ef38af6097650b49bc41b55fabb19f35bd" + dependencies: + inherits "^2.0.1" + readable-stream "^2.0.4" + follow-redirects@^1.2.5: version "1.4.1" resolved "https://registry.yarnpkg.com/follow-redirects/-/follow-redirects-1.4.1.tgz#d8120f4518190f55aac65bb6fc7b85fcd666d6aa" @@ -2555,6 +2645,13 @@ fresh@^0.5.2: version "0.5.2" resolved "https://registry.yarnpkg.com/fresh/-/fresh-0.5.2.tgz#3d8cadd90d976569fa835ab1f8e4b23a105605a7" +from2@^2.1.0: + version "2.3.0" + resolved "https://registry.yarnpkg.com/from2/-/from2-2.3.0.tgz#8bfb5502bde4a4d36cfdeea007fcca21d7e382af" + dependencies: + inherits "^2.0.1" + readable-stream "^2.0.0" + from@~0: version "0.1.7" resolved "https://registry.yarnpkg.com/from/-/from-0.1.7.tgz#83c60afc58b9c56997007ed1a768b3ab303a44fe" @@ -2573,6 +2670,15 @@ fs-extra@3.0.1: jsonfile "^3.0.0" universalify "^0.1.0" +fs-write-stream-atomic@^1.0.8: + version "1.0.10" + resolved "https://registry.yarnpkg.com/fs-write-stream-atomic/-/fs-write-stream-atomic-1.0.10.tgz#b47df53493ef911df75731e70a9ded0189db40c9" + dependencies: + graceful-fs "^4.1.2" + iferr "^0.1.5" + imurmurhash "^0.1.4" + readable-stream "1 || 2" + fs.realpath@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/fs.realpath/-/fs.realpath-1.0.0.tgz#1504ad2523158caa40db4a2787cb01411994ea4f" @@ -2707,7 +2813,7 @@ glob@^5.0.12: once "^1.3.0" path-is-absolute "^1.0.0" -glob@^7.0.0, glob@^7.0.3, glob@^7.0.5, glob@~7.1.1: +glob@^7.0.0, glob@^7.0.3, glob@^7.0.5, glob@^7.1.2, glob@~7.1.1: version "7.1.2" resolved "https://registry.yarnpkg.com/glob/-/glob-7.1.2.tgz#c19c9df9a028702d678612384a6552404c636d15" dependencies: @@ -2759,6 +2865,17 @@ globby@^5.0.0: pify "^2.0.0" pinkie-promise "^2.0.0" +globby@^7.1.1: + version "7.1.1" + resolved "https://registry.yarnpkg.com/globby/-/globby-7.1.1.tgz#fb2ccff9401f8600945dfada97440cca972b8680" + dependencies: + array-union "^1.0.1" + dir-glob "^2.0.0" + glob "^7.1.2" + ignore "^3.3.5" + pify "^3.0.0" + slash "^1.0.0" + globule@^1.0.0: version "1.2.0" resolved "https://registry.yarnpkg.com/globule/-/globule-1.2.0.tgz#1dc49c6822dd9e8a2fa00ba2a295006e8664bd09" @@ -2781,7 +2898,7 @@ glogg@^1.0.0: dependencies: sparkles "^1.0.0" -graceful-fs@4.X, graceful-fs@^4.1.2, graceful-fs@^4.1.6: +graceful-fs@4.X, graceful-fs@^4.1.11, graceful-fs@^4.1.2, graceful-fs@^4.1.6: version "4.1.11" resolved "https://registry.yarnpkg.com/graceful-fs/-/graceful-fs-4.1.11.tgz#0e8bdfe4d1ddb8854d64e04ea7c00e2a026e5658" @@ -3290,6 +3407,14 @@ ieee754@^1.1.4: version "1.1.8" resolved "https://registry.yarnpkg.com/ieee754/-/ieee754-1.1.8.tgz#be33d40ac10ef1926701f6f08a2d86fbfd1ad3e4" +iferr@^0.1.5: + version "0.1.5" + resolved "https://registry.yarnpkg.com/iferr/-/iferr-0.1.5.tgz#c60eed69e6d8fdb6b3104a1fcbca1c192dc5b501" + +ignore@^3.3.5: + version "3.3.7" + resolved "https://registry.yarnpkg.com/ignore/-/ignore-3.3.7.tgz#612289bfb3c220e186a58118618d5be8c1bab021" + ilyabirman-likely@^2.3.0: version "2.3.0" resolved "https://registry.yarnpkg.com/ilyabirman-likely/-/ilyabirman-likely-2.3.0.tgz#4462becc5dedeb36b74bf4ba339a0ceab820785f" @@ -3298,6 +3423,10 @@ immutable@3.8.2, immutable@^3.7.6: version "3.8.2" resolved "https://registry.yarnpkg.com/immutable/-/immutable-3.8.2.tgz#c2439951455bb39913daf281376f1530e104adf3" +imurmurhash@^0.1.4: + version "0.1.4" + resolved "https://registry.yarnpkg.com/imurmurhash/-/imurmurhash-0.1.4.tgz#9218b9b2b928a238b13dc4fb6b6d576f231453ea" + in-publish@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/in-publish/-/in-publish-2.0.0.tgz#e20ff5e3a2afc2690320b6dc552682a9c7fadf51" @@ -3470,7 +3599,7 @@ is-extglob@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/is-extglob/-/is-extglob-1.0.0.tgz#ac468177c4943405a092fc8f29760c6ffc6206c0" -is-extglob@^2.1.0: +is-extglob@^2.1.0, is-extglob@^2.1.1: version "2.1.1" resolved "https://registry.yarnpkg.com/is-extglob/-/is-extglob-2.1.1.tgz#a88c02535791f02ed37c76a1b9ea9773c833f8c2" @@ -3502,6 +3631,12 @@ is-glob@^3.1.0: dependencies: is-extglob "^2.1.0" +is-glob@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/is-glob/-/is-glob-4.0.0.tgz#9521c76845cc2610a85203ddf080a958c2ffabc0" + dependencies: + is-extglob "^2.1.1" + is-number-like@^1.0.3: version "1.0.8" resolved "https://registry.yarnpkg.com/is-number-like/-/is-number-like-1.0.8.tgz#2e129620b50891042e44e9bbbb30593e75cfbbe3" @@ -4407,6 +4542,21 @@ minimist@^1.1.0, minimist@^1.1.1, minimist@^1.1.3, minimist@^1.2.0: version "1.2.0" resolved "https://registry.yarnpkg.com/minimist/-/minimist-1.2.0.tgz#a35008b20f41383eec1fb914f4cd5df79a264284" +mississippi@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/mississippi/-/mississippi-2.0.0.tgz#3442a508fafc28500486feea99409676e4ee5a6f" + dependencies: + concat-stream "^1.5.0" + duplexify "^3.4.2" + end-of-stream "^1.1.0" + flush-write-stream "^1.0.0" + from2 "^2.1.0" + parallel-transform "^1.1.0" + pump "^2.0.1" + pumpify "^1.3.3" + stream-each "^1.1.0" + through2 "^2.0.0" + mixin-deep@^1.2.0: version "1.3.0" resolved "https://registry.yarnpkg.com/mixin-deep/-/mixin-deep-1.3.0.tgz#47a8732ba97799457c8c1eca28f95132d7e8150a" @@ -4431,6 +4581,17 @@ moment@^2.20.1: version "2.20.1" resolved "https://registry.yarnpkg.com/moment/-/moment-2.20.1.tgz#d6eb1a46cbcc14a2b2f9434112c1ff8907f313fd" +move-concurrently@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/move-concurrently/-/move-concurrently-1.0.1.tgz#be2c005fda32e0b29af1f05d7c4b33214c701f92" + dependencies: + aproba "^1.1.1" + copy-concurrently "^1.0.0" + fs-write-stream-atomic "^1.0.8" + mkdirp "^0.5.1" + rimraf "^2.5.4" + run-queue "^1.0.3" + ms@0.7.1: version "0.7.1" resolved "https://registry.yarnpkg.com/ms/-/ms-0.7.1.tgz#9cd13c03adbff25b65effde7ce864ee952017098" @@ -4740,7 +4901,7 @@ on-finished@~2.3.0: dependencies: ee-first "1.1.1" -once@^1.3.0, once@^1.3.3, once@^1.4.0: +once@^1.3.0, once@^1.3.1, once@^1.3.3, once@^1.4.0: version "1.4.0" resolved "https://registry.yarnpkg.com/once/-/once-1.4.0.tgz#583b1aa775961d4b113ac17d9c50baef9dd76bd1" dependencies: @@ -4818,7 +4979,7 @@ p-finally@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/p-finally/-/p-finally-1.0.0.tgz#3fbcfb15b899a44123b34b6dcc18b724336a2cae" -p-limit@^1.1.0: +p-limit@^1.0.0, p-limit@^1.1.0: version "1.2.0" resolved "https://registry.yarnpkg.com/p-limit/-/p-limit-1.2.0.tgz#0e92b6bedcb59f022c13d0f1949dc82d15909f1c" dependencies: @@ -4838,6 +4999,14 @@ pako@~1.0.5: version "1.0.6" resolved "https://registry.yarnpkg.com/pako/-/pako-1.0.6.tgz#0101211baa70c4bca4a0f63f2206e97b7dfaf258" +parallel-transform@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/parallel-transform/-/parallel-transform-1.1.0.tgz#d410f065b05da23081fcd10f28854c29bda33b06" + dependencies: + cyclist "~0.2.2" + inherits "^2.0.3" + readable-stream "^2.1.5" + parse-asn1@^5.0.0: version "5.1.0" resolved "https://registry.yarnpkg.com/parse-asn1/-/parse-asn1-5.1.0.tgz#37c4f9b7ed3ab65c74817b5f2480937fbf97c712" @@ -4949,6 +5118,12 @@ path-type@^2.0.0: dependencies: pify "^2.0.0" +path-type@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/path-type/-/path-type-3.0.0.tgz#cef31dc8e0a1a3bb0d105c0cd97cf3bf47f4e36f" + dependencies: + pify "^3.0.0" + pause-stream@0.0.11: version "0.0.11" resolved "https://registry.yarnpkg.com/pause-stream/-/pause-stream-0.0.11.tgz#fe5a34b0cbce12b5aa6a2b403ee2e73b602f1445" @@ -5320,10 +5495,18 @@ process-nextick-args@~1.0.6: version "1.0.7" resolved "https://registry.yarnpkg.com/process-nextick-args/-/process-nextick-args-1.0.7.tgz#150e20b756590ad3f91093f25a4f2ad8bff30ba3" +process-nextick-args@~2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/process-nextick-args/-/process-nextick-args-2.0.0.tgz#a37d732f4271b4ab1ad070d35508e8290788ffaa" + process@^0.11.10: version "0.11.10" resolved "https://registry.yarnpkg.com/process/-/process-0.11.10.tgz#7332300e840161bda3e69a1d1d91a7d4bc16f182" +promise-inflight@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/promise-inflight/-/promise-inflight-1.0.1.tgz#98472870bf228132fcbdd868129bad12c3c029e3" + promise@^7.0.1: version "7.3.1" resolved "https://registry.yarnpkg.com/promise/-/promise-7.3.1.tgz#064b72602b18f90f29192b8b1bc418ffd1ebd3bf" @@ -5445,6 +5628,21 @@ pug-walk@^1.1.5: pug-runtime "^2.0.3" pug-strip-comments "^1.0.2" +pump@^2.0.0, pump@^2.0.1: + version "2.0.1" + resolved "https://registry.yarnpkg.com/pump/-/pump-2.0.1.tgz#12399add6e4cf7526d973cbc8b5ce2e2908b3909" + dependencies: + end-of-stream "^1.1.0" + once "^1.3.1" + +pumpify@^1.3.3: + version "1.4.0" + resolved "https://registry.yarnpkg.com/pumpify/-/pumpify-1.4.0.tgz#80b7c5df7e24153d03f0e7ac8a05a5d068bd07fb" + dependencies: + duplexify "^3.5.3" + inherits "^2.0.3" + pump "^2.0.0" + punycode@1.3.2: version "1.3.2" resolved "https://registry.yarnpkg.com/punycode/-/punycode-1.3.2.tgz#9653a036fb7c1ee42342f2325cceefea3926c48d" @@ -5547,6 +5745,18 @@ read-pkg@^2.0.0: normalize-package-data "^2.3.2" path-type "^2.0.0" +"readable-stream@1 || 2", readable-stream@^2.0.4, readable-stream@^2.2.2: + version "2.3.5" + resolved "https://registry.yarnpkg.com/readable-stream/-/readable-stream-2.3.5.tgz#b4f85003a938cbb6ecbce2a124fb1012bd1a838d" + dependencies: + core-util-is "~1.0.0" + inherits "~2.0.3" + isarray "~1.0.0" + process-nextick-args "~2.0.0" + safe-buffer "~5.1.1" + string_decoder "~1.0.3" + util-deprecate "~1.0.1" + "readable-stream@>=1.0.33-1 <1.1.0-0", readable-stream@~1.0.17: version "1.0.34" resolved "https://registry.yarnpkg.com/readable-stream/-/readable-stream-1.0.34.tgz#125820e34bc842d2f2aaafafe4c2916ee32c157c" @@ -5807,7 +6017,7 @@ right-align@^0.1.1: dependencies: align-text "^0.1.1" -rimraf@2, rimraf@^2.2.8, rimraf@^2.5.1, rimraf@^2.6.1: +rimraf@2, rimraf@^2.2.8, rimraf@^2.5.1, rimraf@^2.5.4, rimraf@^2.6.1, rimraf@^2.6.2: version "2.6.2" resolved "https://registry.yarnpkg.com/rimraf/-/rimraf-2.6.2.tgz#2ed8150d24a16ea8651e6d6ef0f47c4158ce7a36" dependencies: @@ -5820,6 +6030,12 @@ ripemd160@^2.0.0, ripemd160@^2.0.1: hash-base "^2.0.0" inherits "^2.0.1" +run-queue@^1.0.0, run-queue@^1.0.3: + version "1.0.3" + resolved "https://registry.yarnpkg.com/run-queue/-/run-queue-1.0.3.tgz#e848396f057d223f24386924618e25694161ec47" + dependencies: + aproba "^1.1.1" + run-sequence@^1.1.5: version "1.2.2" resolved "https://registry.yarnpkg.com/run-sequence/-/run-sequence-1.2.2.tgz#5095a0bebe98733b0140bd08dd80ec030ddacdeb" @@ -5912,6 +6128,10 @@ sequencify@~0.0.7: version "0.0.7" resolved "https://registry.yarnpkg.com/sequencify/-/sequencify-0.0.7.tgz#90cff19d02e07027fd767f5ead3e7b95d1e7380c" +serialize-javascript@^1.4.0: + version "1.4.0" + resolved "https://registry.yarnpkg.com/serialize-javascript/-/serialize-javascript-1.4.0.tgz#7c958514db6ac2443a8abc062dc9f7886a7f6005" + serve-index@1.8.0: version "1.8.0" resolved "https://registry.yarnpkg.com/serve-index/-/serve-index-1.8.0.tgz#7c5d96c13fb131101f93c1c5774f8516a1e78d3b" @@ -6222,6 +6442,12 @@ sshpk@^1.7.0: jsbn "~0.1.0" tweetnacl "~0.14.0" +ssri@^5.2.4: + version "5.3.0" + resolved "https://registry.yarnpkg.com/ssri/-/ssri-5.3.0.tgz#ba3872c9c6d33a0704a7d71ff045e5ec48999d06" + dependencies: + safe-buffer "^5.1.1" + static-extend@^0.1.1: version "0.1.2" resolved "https://registry.yarnpkg.com/static-extend/-/static-extend-0.1.2.tgz#60809c39cbff55337226fd5e0b520f341f1fb5c6" @@ -6254,6 +6480,13 @@ stream-consume@~0.1.0: version "0.1.0" resolved "https://registry.yarnpkg.com/stream-consume/-/stream-consume-0.1.0.tgz#a41ead1a6d6081ceb79f65b061901b6d8f3d1d0f" +stream-each@^1.1.0: + version "1.2.2" + resolved "https://registry.yarnpkg.com/stream-each/-/stream-each-1.2.2.tgz#8e8c463f91da8991778765873fe4d960d8f616bd" + dependencies: + end-of-stream "^1.1.0" + stream-shift "^1.0.0" + stream-http@^2.7.2: version "2.8.0" resolved "https://registry.yarnpkg.com/stream-http/-/stream-http-2.8.0.tgz#fd86546dac9b1c91aff8fc5d287b98fafb41bc10" @@ -6559,6 +6792,10 @@ tweetnacl@^0.14.3, tweetnacl@~0.14.0: version "0.14.5" resolved "https://registry.yarnpkg.com/tweetnacl/-/tweetnacl-0.14.5.tgz#5ae68177f192d4456269d108afa93ff8743f4f64" +typedarray@^0.0.6: + version "0.0.6" + resolved "https://registry.yarnpkg.com/typedarray/-/typedarray-0.0.6.tgz#867ac74e3864187b1d3d47d996a78ec5c8830777" + ua-parser-js@0.7.12: version "0.7.12" resolved "https://registry.yarnpkg.com/ua-parser-js/-/ua-parser-js-0.7.12.tgz#04c81a99bdd5dc52263ea29d24c6bf8d4818a4bb" @@ -6619,6 +6856,18 @@ uniqs@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/uniqs/-/uniqs-2.0.0.tgz#ffede4b36b25290696e6e165d4a59edb998e6b02" +unique-filename@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/unique-filename/-/unique-filename-1.1.0.tgz#d05f2fe4032560871f30e93cbe735eea201514f3" + dependencies: + unique-slug "^2.0.0" + +unique-slug@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/unique-slug/-/unique-slug-2.0.0.tgz#db6676e7c7cc0629878ff196097c78855ae9f4ab" + dependencies: + imurmurhash "^0.1.4" + unique-stream@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/unique-stream/-/unique-stream-1.0.0.tgz#d59a4a75427447d9aa6c91e70263f8d26a4b104b" @@ -6984,6 +7233,10 @@ y18n@^3.2.0, y18n@^3.2.1: version "3.2.1" resolved "https://registry.yarnpkg.com/y18n/-/y18n-3.2.1.tgz#6d15fba884c08679c0d77e88e7759e811e07fa41" +y18n@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/y18n/-/y18n-4.0.0.tgz#95ef94f85ecc81d007c264e190a120f0a3c8566b" + yallist@^2.1.2: version "2.1.2" resolved "https://registry.yarnpkg.com/yallist/-/yallist-2.1.2.tgz#1c11f9218f076089a47dd512f93c6699a6a81d52"