Современные устройства предоставляют такие современные удобства, как покупки с мобильного устройства, где бы вы ни находились. Используя NativeScript Vue, я расскажу, как создать приложение для покупок с каталогом продуктов, которое использует Firebase и Stripe для обработки платежей с кредитной карты.

Это приложение будет полагаться на Firebase для нужд нашего сервера, предоставляя услуги аутентификации, базы данных и облачных функций. Если вы раньше не работали с Firebase, вам следует прочитать мой предыдущий пост, в котором я объясняю, как создать проект Firebase. Выполните те же действия, чтобы настроить новое приложение Firebase, зарегистрируйте его для использования с Android и iOS и включите электронную почту / пароль в разделе Методы аутентификации / входа в консоли Firebase. Вы также можете обновить шаблоны, которые Firebase использует для отправки писем об активации и сбросе пароля, на вкладке Шаблоны на этой странице.

Создайте новое приложение NativeScript Vue с помощью команды CLI tns create ns6shopping и выберите пустой шаблон Vue. Это будет однокадровое приложение, которое будет состоять из страницы входа, галереи продуктов, страницы сведений о продукте и страницы оплаты. Если вы хотите поэкспериментировать с многокадровыми приложениями, вы можете создать их с помощью шаблона Tabs Vue и работать с одной вкладкой, чтобы создать рамки для страниц магазина внутри. Отредактируйте /app.js и замените его следующим:

import Vue from "nativescript-vue";
import Login from "./components/Login";
new Vue({
    template: `
        <Frame>
            <Login />
        </Frame>`,
    components: {
        Login
    }
}).$start();

Это указывает приложению инициализировать и загрузить страницу входа в один фрейм. Добавьте новую страницу входа как /app/components/Login.vue:

<template>
    <Page actionBarHidden="true" backgroundSpanUnderStatusBar="true">
        <FlexboxLayout class="page">
            <StackLayout class="form">
                <Image class="logo" src="~/images/NativeScript-Vue.png" />
                <Label class="header" text="SHOPPER" />
                <StackLayout class="input-field" marginBottom="25">
                    <TextField class="input" hint="Email" keyboardType="email" autocorrect="false" autocapitalizationType="none" v-model="user.email" returnKeyType="next" @returnPress="focusPassword" fontSize="18" />
                    <StackLayout class="hr-light" />
                </StackLayout>
                <StackLayout class="input-field" marginBottom="25">
                    <TextField ref="password" class="input" hint="Password" secure="true" v-model="user.password" :returnKeyType="isLoggingIn ? 'done' : 'next'" @returnPress="focusConfirmPassword" fontSize="18" />
                    <StackLayout class="hr-light" />
                </StackLayout>
                <StackLayout v-show="!isLoggingIn" class="input-field">
                    <TextField ref="confirmPassword" class="input" hint="Confirm password" secure="true" v-model="user.confirmPassword" returnKeyType="done" fontSize="18" />
                    <StackLayout class="hr-light" />
                </StackLayout>
                <Button :text="isLoggingIn ? 'Log In' : 'Sign Up'" @tap="submit" class="btn btn-primary m-t-20" />
                <Label v-show="isLoggingIn" text="Forgot your password?" class="login-label" @tap="forgotPassword" />
                <Label class="login-label sign-up-label" @tap="toggleForm">
                    <FormattedString>
                    	<Span :text="isLoggingIn ? 'Don’t have an account? ' : 'Back to Login'" />
                    	<Span :text="isLoggingIn ? 'Sign up' : ''" class="bold" />
                    </FormattedString>
                </Label>
            </StackLayout>
        </FlexboxLayout>
    </Page>
</template>

<script>
import HomePage from "./Home";
var firebase = require("nativescript-plugin-firebase"); 
export default {
    data() {
        return {
            isLoggingIn: true,
            user: {
                email: "[email protected]",
                password: "foo",
                confirmPassword: "foo"
            },
            userService: {
                register(user) {
                    return Promise.resolve(user);
                },
                login(user) {
                    return Promise.resolve(user);
                },
                resetPassword(email) {
                    return Promise.resolve(email);
                }
            }
        };
    },
    mounted(){
        firebase
            .init({
            })
            .then(
                function(instance) {
                    console.log("firebase.init done");
                },
                function(error) {
                    console.log("firebase.init error: " + error);
                }
            );
    },
    methods: {
        toggleForm() {
            this.isLoggingIn = !this.isLoggingIn;
        },
        submit() {
            if (!this.user.email || !this.user.password) {
                this.alert("Please provide both an email address and password.");
                return;
            }
            if (this.isLoggingIn) {
                this.login();
            } else {
                this.register();
            }
        },
        login() {
            let that = this
            this.userService
                .login(this.user)
                .then(() => {
                    this.$navigateTo(HomePage, { clearHistory: true });
                })
                .catch(() => {
                    this.alert("Unfortunately we could not find your account.");
                });
        },
        register() {
            if (this.user.password != this.user.confirmPassword) {
                this.alert("Your passwords do not match.");
                return;
            }
            this.userService
                .register(this.user)
                .then(() => {
                    this.alert("Your account was successfully created.");
                    this.isLoggingIn = true;
                })
                .catch(() => {
                    this.alert("Unfortunately we were unable to create your account.");
                });
        },
        forgotPassword() {
            let that = this
            prompt({
                title: "Forgot Password",
                message: "Enter the email address you used to register for APP NAME to reset your password.",
                inputType: "email",
                defaultText: "",
                okButtonText: "Ok",
                cancelButtonText: "Cancel"
            }).then(data => {
                if (data.result) {
                    that.userService
                        .resetPassword(data.text.trim())
                        .then(() => {
                            that.alert(
                                "Your password was successfully reset. Please check your email for instructions on choosing a new password."
                            );
                        })
                        .catch(() => {
                            that.alert(
                                "Unfortunately, an error occurred resetting your password."
                            );
                        });
                }
            });
        },
        focusPassword() {
            this.$refs.password.nativeView.focus();
        },
        focusConfirmPassword() {
            if (!this.isLoggingIn) {
                this.$refs.confirmPassword.nativeView.focus();
            }
        },
        alert(message) {
            return alert({
                title: "Shopper",
                okButtonText: "OK",
                message: message
            });
        }
    }
};
</script>	
<style scoped>
.page {
    align-items: center;
    flex-direction: column;
}
.form {
    margin-left: 30;
    margin-right: 30;
    flex-grow: 2;
    vertical-align: middle;
}
.logo {
    margin-bottom: 12;
    height: 90;
    font-weight: bold;
}
.header {
    horizontal-align: center;
    font-size: 25;
    font-weight: 600;
    margin-bottom: 70;
    text-align: center;
    color: rgb(51, 51, 206);
}
.input-field {
    margin-bottom: 25;
}
.input {
    font-size: 18;
    placeholder-color: #A8A8A8;
}
.input-field .input {
    font-size: 54;
}
.btn-primary {
    height: 50;
    margin: 30 5 15 5;
    background-color: rgb(51, 51, 206);
    color: white;
    border-radius: 5;
    font-size: 20;
    font-weight: 600;
}
.login-label {
    horizontal-align: center;
    color: #A8A8A8;
    font-size: 16;
}
.sign-up-label {
    margin-bottom: 20;
}
.bold {
    color: #000000;
}
</style>

Эта страница была объяснена более подробно в предыдущем посте на тот случай, если вы захотите узнать, что здесь происходит. Посмотрев на эту страницу, вы увидите, что у нас есть единственная страница, которая обрабатывает запросы входа, регистрации и забытого пароля, на данный момент привязанные к фиктивным функциям. Мы использовали изображение логотипа NativeScript Vue из предыдущего сообщения, скопированное в/app/images/NativeScript-Vue.png.

Запустите приложение на симуляторе ios, и вы должны увидеть firebase.init done, напечатанный на консоли, если оно работает правильно. Убедитесь, что вы загрузили файлы конфигурации Android и iOS из консоли Firebase и поместите их в соответствующие каталоги в app/App_Resources, если нет. Однако, если вы попытаетесь запустить это на Android, вы, вероятно, столкнетесь со следующей ошибкой:

Чтобы решить эту проблему, мы добавим следующее в раздел defaultConfig в app/App_Resources/Android/app.gradle:

multiDexEnabled true

Теперь вы должны увидеть то же сообщение об успешной инициализации при запуске на Android. Запустите приложение, и вы должны увидеть что-то вроде:

Наш интерфейс для входа в систему готов, и теперь мы будем работать над интеграцией Firebase для предоставления услуг аутентификации.

Аутентификация с Firebase

Прежде чем продолжить использование, нам необходимо установить плагин Firebase для NativeScript:

tns plugin add nativescript-plugin-firebase

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

tns plugin install @nstudio/nativescript-loading-indicator

При отображении индикатора требуется объект параметров по умолчанию, который мы добавим в app/app.js после импорта и доступный через глобальный объект во всем приложении.

global.loaderOptions = {
    android: {
        margin: 100,
        dimBackground: true,
        color: "#4B9ED6", 
        hideBezel: true, 
        mode: 3 
    },
    ios: {
        dimBackground: true,
        color: "#FFFFFF", 
        hideBezel: true, 
        mode: 3 
    }
};

Давайте обновим методы login, register и forgotPassword в app/components/Login.vue, чтобы использовать загрузчик в ожидании завершения операций Firebase:

login() {
            let that = this
            loader.show(global.loaderOptions);
            this.userService
                .login(this.user)
                .then(() => {
                    loader.hide()
                    this.$navigateTo(HomePage, { clearHistory: true });
                })
                .catch((err) => {
                    loader.hide()
                    console.log(err)
                    this.alert("Unfortunately we could not find your account.");
                });
        },
        register() {
            if (this.user.password != this.user.confirmPassword) {
                this.alert("Your passwords do not match.");
                return;
            }
            if (this.user.password.length < 6) {
                this.alert("Your password must be at least 6 characters.");
                return;
            }
            loader.show(global.loaderOptions);
            this.userService
                .register(this.user)
                .then(() => {
                    this.isLoggingIn = true;
                    loader.hide()
                })
                .catch((err) => {
                    loader.hide()
                    console.log(err)
                    this.alert("Unfortunately we were unable to create your account.");
                });
        },
        forgotPassword() {
            let that = this
            prompt({
                title: "Forgot Password",
                message: "Enter the email address you used to register for APP NAME to reset your password.",
                inputType: "email",
                defaultText: "",
                okButtonText: "Ok",
                cancelButtonText: "Cancel"
            }).then(data => {
                if (data.result) {
                    loader.show(global.loaderOptions);
                    that.userService
                        .resetPassword(data.text.trim())
                        .then(() => {
                            loader.hide()
                            that.alert(
                                "Your password was successfully reset. Please check your email for instructions on choosing a new password."
                            );
                        })
                        .catch(() => {
                            loader.hide()
                            that.alert(
                                "Unfortunately, an error occurred resetting your password."
                            );
                        });
                }
            });
        },

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

async register(user) {
                    return await firebase.createUser({
                        email: user.email,
                        password: user.password,
                    }).then(
                        function(response) {
                            firebase.sendEmailVerification().then(function() {
                                    alert("A verification email has been sent, click on the link to activate your account")
                                },
                                function(error) {
                                    console.error("Error sending email verification: ", error);
                                }
                            )
                        })
                },

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

Заполните userService функцию входа в систему соответствующей функцией входа в Firebase:

async login(user) {
                    return await firebase.login({
                        type: firebase.LoginType.PASSWORD,
                        passwordOptions: {
                            email: user.email,
                            password: user.password,
                        }
                    })
                },

Теперь мы изменим основной login метод, чтобы проверить, подтвердил ли пользователь свой адрес электронной почты, прежде чем разрешить вход:

login() {
            let that = this
            loader.show(global.loaderOptions);
            this.userService
                .login(this.user)
                .then((currentUser) => {
                    loader.hide()
                    if (!currentUser.emailVerified) {
                        this.alert("Please click on the link in the verification email sent during registration. Check your Spam folder for a new link we've just emailed.");
                        firebase.sendEmailVerification().then(function() { console.log("email sent") },
                            function(error) {
                                console.error("Error sending email verification: ", error);
                            }
                        )
                        return false;
                    }
                    this.$navigateTo(HomePage);
                })
                .catch((err) => {
                    loader.hide()
                    console.log(err)
                    this.alert("Unfortunately we could not find your account.");
                });
        },

Если пользователь пытается войти в систему без подтверждения, мы покажем предупреждение с просьбой подтвердить его по ссылке, отправленной по электронной почте. Мы могли бы показать новую ссылку для запроса другого письма с подтверждением, используя currentUser.emailVerified в качестве флага, но для этого сообщения мы просто будем отправлять новое письмо с подтверждением каждый раз, когда они будут пытаться войти в систему, если они не получили письмо.

Теперь заполните функцию userService resetPassword, которая заставит Firebase отправить электронное письмо со ссылкой для изменения пароля:

async resetPassword(email) {
	return await firebase.sendPasswordResetEmail(email)
}

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

Создание каталога магазина

Давайте создадим страницу со списком товаров для продажи. Чтобы избежать стилизации, мы воспользуемся плагином NativeScript Cardview для отображения продуктов внутри Material Design CardViews, при этом карточки будут отображаться в формате 2 столбца. Установите плагин, используя:

tns plugin add @nstudio/nativescript-cardview

Мы импортируем его как компонент внутрь /app/app.js:

Vue.registerElement(
    "CardView",
    () => require("@nstudio/nativescript-cardview").CardView
);

Замените содержимое app/components/Home.vue на:

<template>
    <Page>
        <ActionBar>
            <Label text="Shop"></Label>
        </ActionBar>
        <ScrollView>
            <StackLayout>
                <GridLayout rows="*" columns="*, *" v-if="rowCount>0" v-for="i in rowCount" :key="i">
                    <CardView class="card" margin="10" col="0" elevation="20" v-if="Items[(i - 1) * itemsPerRow] && Items[(i - 1) * itemsPerRow ].name" @tap="seeDetails(Items[(i - 1) * itemsPerRow])">
                        <GridLayout class="card-layout" rows="120, auto,auto,auto" columns="*, *, *">
                            <Image v-if="Items[(i - 1) * itemsPerRow].image" :src="Items[(i - 1) * itemsPerRow].image" stretch="aspectFill" colSpan="3" row="0" />
                            <Label :text="Items[(i - 1) * itemsPerRow].name" class="" row="1" colSpan="3" />
                            <Label :text="Items[(i - 1) * itemsPerRow].price | dollars" class="" row="2" colSpan="3" />
                            <Button row="3" colSpan="3" text="Buy" @tap="addItem(Items[(i - 1) * itemsPerRow])" class="btn m-t-20 add-button" />
                        </GridLayout>
                    </CardView>
                    <CardView class="card" margin="10" col="1" elevation="20" v-if="Items[(i - 1) * itemsPerRow +1] && Items[(i - 1) * itemsPerRow +1].name" @tap="seeDetails(Items[(i - 1) * itemsPerRow +1])">
                        <GridLayout class="card-layout" rows="120, auto,auto,auto" columns="*, *, *">
                            <Image v-if="Items[(i - 1) * itemsPerRow+1].image" :src="Items[(i - 1) * itemsPerRow + 1].image" stretch="aspectFill" colSpan="3" row="0" />
                            <Label :text="Items[(i - 1) * itemsPerRow + 1].name" class="" row="1" colSpan="3" />
                            <Label :text="Items[(i - 1) * itemsPerRow +1].price | dollars" class="" row="2" colSpan="3" />
                            <Button row="3" colSpan="3" text="Buy" @tap="addItem(Items[(i - 1) * itemsPerRow +1])" class="btn m-t-20 add-button" />
                        </GridLayout>
                    </CardView>
                </GridLayout>
            </StackLayout>
        </ScrollView>
    </Page>
</template>

<script>
export default {
    data() {
        return {
            Items: [
                { invId: 1, name: "An Item", image: "https://picsum.photos/300/200", price: 999, description: "This round bottle is made of opaque bright rose glass.  It has a mid-length neck, stands about seven inches tall, and the ink on its label has been washed off." },
                { invId: 2, name: "Thing", image: "https://picsum.photos/300/200", price: 1499, description: "This round bottle is made of opaque chartreuse glass.  It has a mid-length neck, stands about six inches tall, and the ink on its label has been washed off." },
                { invId: 3, name: "Doo-dad", image: "https://picsum.photos/300/200", price: 499, description: "This coffin-shaped bottle is made of opaque lilac glass.  It has a long neck, stands about five inches tall, and it has no label." },
                { invId: 4, name: "Other thing", image: "https://picsum.photos/300/200", price: 299, description: "This cylindrical bottle is made of transparent bright turquoise glass.  It has a mid-length neck, stands about twelve inches tall, and it has a simple printed label." },
                { invId: 5, name: "Last One", image: "https://picsum.photos/300/200", price: 899, description: "This teardrop-shaped bottle is made of translucent bright purple glass.  It has a mid-length neck, stands about eleven inches tall, and most of its label has been torn off." }
            ],
            itemsPerRow: 2
        }
    },
    computed: {
        rowCount: function() {
            return Math.ceil(this.Items.length / this.itemsPerRow);
        },
    },
    filters: {
        dollars: num => `$${num / 100}`
    },
    methods: {
        seeDetails(item) {
            console.log("Showing detailed view for: ");
            console.dir(item);
        },
        addItem(item) {
            console.log("Adding item:");
            console.dir(item);
        }
    },
};
</script>

<style scoped lang="scss">
@import '~@nativescript/theme/scss/variables/blue'; 
.add-button {
    height: 30;
    background-color: rgb(51, 51, 206);
    color: white;
    border-radius: 5;
    font-size: 20;
    font-weight: 600;
}

.card {
    background-color: #fff;
    color: #4d4d4d;
    margin: 15;
}

.card-layout {
    padding: 20;
}
</style>

Мы определяем 5 примеров элементов, используя URL-адреса изображений с веб-сайта Lorem Picsum, который обслуживает случайные изображения с заданными размерами. Запустите приложение, войдите в систему, и вы должны увидеть основную страницу каталога:

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

Использование FireStore DB

Вернитесь в Консоль Firebase, перейдите в раздел Базы данных для своего проекта и создайте новую БД Firestore для приложения. Когда вас попросят, выберите производственные правила безопасности (ограниченное чтение / запись), хотя мы изменим их на правила разработки, пока работаем над приложением. После создания базы данных перейдите на вкладку правил и измените доступ для чтения / записи на true:

Теперь вернитесь на вкладку с основными данными и начните новую коллекцию для товаров нашего магазина. Мы назовем эту коллекцию Items и начнем добавлять документы для каждого элемента с теми же полями и значениями, что и массив Items в Home.vue. Первый элемент будет выглядеть так:

После того, как вы добавили примеры элементов, давайте обновим Home.vue, чтобы начать читать их из Firebase Firestore. Мы добавим импорт для библиотеки Firebase, закомментируем записи массива Items и добавим функцию created для загрузки элементов из Firestore при загрузке страницы. Мы также подготовим две новые страницы для отображения информации о товаре и покупки товара. Измените верхний раздел скрипта так, чтобы он выглядел так:

<script>
import firebase from "nativescript-plugin-firebase";
import ItemDetail from "./ItemDetail";
import Payment from "./Payment";
export default {
    data() {
        return {
            Items: [
                // { invId: 1, name: "An Item", image: "https://picsum.photos/300/200", price: 999, description: "This round bottle is made of opaque bright rose glass.  It has a mid-length neck, stands about seven inches tall, and the ink on its label has been washed off." },
                // { invId: 2, name: "Thing", image: "https://picsum.photos/300/200", price: 1499, description: "This round bottle is made of opaque chartreuse glass.  It has a mid-length neck, stands about six inches tall, and the ink on its label has been washed off." },
                // { invId: 3, name: "Doo-dad", image: "https://picsum.photos/300/200", price: 499, description: "This coffin-shaped bottle is made of opaque lilac glass.  It has a long neck, stands about five inches tall, and it has no label." },
                // { invId: 4, name: "Other thing", image: "https://picsum.photos/300/200", price: 299, description: "This cylindrical bottle is made of transparent bright turquoise glass.  It has a mid-length neck, stands about twelve inches tall, and it has a simple printed label." },
                // { invId: 5, name: "Last One", image: "https://picsum.photos/300/200", price: 899, description: "This teardrop-shaped bottle is made of translucent bright purple glass.  It has a mid-length neck, stands about eleven inches tall, and most of its label has been torn off." }
            ],
            itemsPerRow: 2
        }
    },
    created() {
        let that = this
        firebase.firestore
            .collection("Items")
            .get()
            .then(snapshot => {
                let itemArr = [];
                snapshot.forEach(document => {
                    itemArr.push(document.data());
                });
                that.Items = itemArr
            });
    },
    methods: {
        seeDetails(item) {
            this.$navigateTo(ItemDetail, { props: { item: item } });
        },
        addItem(item) {
            this.$navigateTo(Payment, { props: { item: item } });
   		}
    }

Давайте добавим страницу сведений об элементе как app/components/ItemDetail.vue:

<template>
    <Page backgroundSpanUnderStatusBar="true">
        <ActionBar>
            <Label text="Details"></Label>
        </ActionBar>
        <ScrollView>
            <StackLayout>
                <CardView class="card" margin="10" col="0" elevation="20">
                    <GridLayout class="card-layout" rows="400, auto,auto,auto,auto,auto" columns="*, *, *">
                        <Image :src="item.image" stretch="aspectFill" colSpan="3" row="0" />
                        <Label :text="item.name" class="item-name" row="1" colSpan="3" />
                        <Label :text="item.price| dollars" class="item-price" row="2" colSpan="3" />
                        <Button row="3" colSpan="2" text="Buy" @tap="buyItem(item)" class="btn btn-primary m-t-20 add-button" />
                        <StackLayout class="line" row="4" colSpan="3" />
                        <TextView editable="false" row="5" colSpan="3" class="item-desc" textWrap="true" :text="item.description" />
                    </GridLayout>
                </CardView>
            </StackLayout>
        </ScrollView>
    </Page>
</template>

<script>
import Payment from "./Payment";
export default {
    components: {},
    filters: {
        dollars: num => `$${num / 100}`
    },
    props: {
        item: {
            type: Object,
            required: true
        }
    },
    data() {
        return {
        }
    },
    computed: {
    },
    methods: {
        buyItem(item) {
            this.$navigateTo(Payment, { props: { item: item } });
        }
    }
};
</script>

<style scoped>
.card {
    background-color: #fff;
    color: #4d4d4d;
    margin: 15;
}

.card-layout {
    padding: 20;
}

.line {
    background-color: #cecece;
    height: 1;
    margin: 0;
    padding: 4;
}

.item-name {
    font-size: 16;
    color: black;
}

.item-price {
    font-size: 14;
    color: rgb(54, 54, 54);
}

.item-desc {
    font-size: 16;
    color: black;
    padding-bottom: 10;
    background-color: transparent;
    border-color: transparent;
}

.add-button {
    height: 30;
    background-color: rgb(51, 51, 206);
    color: white;
    border-radius: 5;
    font-size: 20;
    font-weight: 600;
}
</style>

Если вы снова запустите приложение, вы все равно должны увидеть те же элементы, которые читаются из Firestore и отображаются приложением (с разными изображениями на iOS и одним и тем же изображением на Android из-за того, как изображения URL загружаются / кешируются каждой платформой). Если вы не создадите пустой файл с именем app/components/Payment.vue, вы получите несколько ошибок, о которых мы позаботимся после того, как подготовим наше приложение для работы с Stripe для платежей.

Полоса

Давайте добавим плагин Stripe, используя tns plugin add nativescript-stripe. Нам нужно будет импортировать виджет Stripe Credit Card и объявить наш общедоступный токен Stripe, изменив app/app.js так, чтобы он выглядел так:

import Vue from "nativescript-vue";
import Login from "./components/Login";
const application = require("tns-core-modules/application");

Vue.config.silent = false;
global.loaderOptions = {
    android: {
        margin: 100,
        dimBackground: true,
        color: "#4B9ED6",
        hideBezel: true,
        mode: 3
    },
    ios: {
        dimBackground: true,
        color: "#FFFFFF",
        hideBezel: true,
        mode: 3
    }
};
Vue.registerElement(
    "CardView",
    () => require("@nstudio/nativescript-cardview").CardView
);
Vue.registerElement(
    "CreditCardView",
    () => require("nativescript-stripe").CreditCardView
);
application.on(application.launchEvent, args => {
    if (args.ios) {
        STPPaymentConfiguration.sharedConfiguration().publishableKey = "YOUR_STRIPE_PUBLIC_TEST_KEY";
    }
});
new Vue({
    template: `
        <Frame>
            <Login />
        </Frame>`,
    components: {
        Login
    }
}).$start();

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

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

npm install -g firebase-tools
mkdir functions
cd functions
firebase login
firebase init functions

Команда командной строки входа в Firebase должна вызвать всплывающее окно с запросом разрешения на использование вашей учетной записи Google с интерфейсом командной строки Firebase. Авторизуйте его с помощью учетной записи Google, которая использовалась для создания проекта Firebase для этого приложения. После авторизации вы можете запустить команду CLI init и выбрать использование существующего проекта с Javascript в качестве языка и позволить ему установить зависимости с помощью NPM.

Теперь давайте добавим облачную функцию для обработки новых записей в коллекцию Payments в Firestore. Сначала добавьте библиотеку Stripe с npm install stripe --save, которая понадобится для вызова Stripe API из облачной функции. Отредактируйте functions/functions/index.js так, чтобы он выглядел так:

const functions = require("firebase-functions");
const admin = require("firebase-admin");
admin.initializeApp(functions.config().firebase);
const stripe = require("stripe")(functions.config().stripe.token);
exports.stripeCharge = functions.firestore
    .document("/Payments/{paymentId}")
    .onCreate(async (event, context) => {
        const payment = event.data();
        const paymentId = context.params.paymentId;
        const amount = payment.amount; // amount must be in cents
        const source = payment.token;
        const currency = "usd";
        const description = "Shopping App Purchase";
        const newcharge = {
            amount,
            currency,
            description,
            source
        };
        return await stripe.charges
            .create(newcharge, {
                idempotencyKey: paymentId //used to prevent double charges for the same payment request
            })
            .then(async function(charge, err) {
                if (err) console.error("error!:", err);
                else {
                    console.log("charge:", charge);
                    return charge;
                }
            })
            .then(newcharge => {
                return admin
                    .firestore()
                    .collection("Payments")
                    .doc(paymentId)
                    .update({ charge: newcharge })
                    .then(function(res) {
                        console.log("Updated DB", res);
                    })
                    .catch(err => {
                        console.error(err);
                    });
            });
    });

Эта функция срабатывает всякий раз, когда в коллекцию Payments в Firestore добавляется новый документ. Сначала он отправит запрос на оплату на платформу Stripe, и в случае успеха сохранит квитанцию ​​об оплате обратно в платежный документ для этой покупки. Мы используем операции async / await, чтобы гарантировать, что и вызов Stripe, и обновления базы данных выполняются и завершаются до того, как демон выполнения облачной функции завершит работу и потеряет авторизацию доступа.

Затем добавьте свой закрытый тестовый ключ в конфигурацию Firebase Cloud Functions с помощью интерфейса командной строки, используя:

firebase functions:config:set stripe.token=<YOUR PRIVATE STRIPE TEST API KEY>

и разверните функцию с помощью:

firebase deploy --only functions

Если вы получаете сообщение об отсутствии пакета Stripe во время развертывания, убедитесь, что в package.json зависимостях есть ссылка с записью, похожей на "stripe": "^8.4.0".

Теперь давайте создадим страницу оплаты в нашем приложении, чтобы пользователь мог ввести данные своей кредитной карты и совершить покупку. Добавьте страницу оплаты как app/components/Payment.vue:

<template>
    <Page backgroundSpanUnderStatusBar="true">
        <ActionBar>
            <Label text="Payment"></Label>
        </ActionBar>
        <ScrollView>
            <StackLayout>
                <CreditCardView ref="ccview"></CreditCardView>
                <GridLayout class="" rows="*,*,*,*,*,*" columns="100,*">
                    <Label ref="name" text="Name:" class="" row="0" col="0" />
                    <TextField class="input" row="0" col="1" hint="Joe Buyer" v-model="buyer.name" fontSize="18" />
                    <Label text="Email:" class="" row="1" col="0" />
                    <TextField ref="email" class="input" row="1" col="1" hint="[email protected]" v-model="buyer.email" fontSize="18" />
                    <Label text="Address:" class="" row="2" col="0" />
                    <TextField ref="address" class="input" row="2" col="1" hint="2025 Thorntree Drive" v-model="buyer.address" fontSize="18" />
                    <Label text="City:" class="" row="3" col="0" />
                    <TextField ref="city" class="input" row="3" col="1" hint="Wallawalla" v-model="buyer.city" fontSize="18" />
                    <Label text="State:" class="" row="4" col="0" />
                    <TextField ref="state" class="input" row="4" col="1" hint="Washington" v-model="buyer.state" fontSize="18" />
                    <Label text="Postal Code:" textWrap="true" class="" row="5" col="0" />
                    <TextField ref="postalcode" class="input" row="5" col="1" hint="38291" v-model="buyer.postalcode" fontSize="18" />
                </GridLayout>
                <GridLayout rows="*" columns="*, *">
                    <Button row="0" col="0" text="Cancel" @tap="$navigateBack()" class="btn cancel-button m-t-20 " />
                    <Button row="0" col="1" text="Submit" @tap="submitPayment()" class="btn buy-button m-t-20" />
                </GridLayout>
                <CardView class="card" margin="10" elevation="20" @tap="seeDetails(item)">
                    <GridLayout class="card-layout" rows="50" columns="50, *,*,*">
                        <Image :src="item.image" stretch="aspectFill" col="0" row="0" />
                        <Label :text="item.name" class="" row="0" col="1" />
                        <Label :text="item.price | dollars" class="" row="0" col="2" />
                    </GridLayout>
                </CardView>
                <StackLayout class="line" />
                <CardView class="card" margin="10" elevation="20">
                    <GridLayout class="card-layout" rows="50" columns="50, *,*">
                        <Label text="Total:" class="" row="0" col="1" />
                        <Label :text="total | dollars" class="" row="0" col="2" />
                    </GridLayout>
                </CardView>
            </StackLayout>
        </ScrollView>
    </Page>
</template>

<script>
import firebase from "nativescript-plugin-firebase";
import ItemDetail from "./ItemDetail";
import { Stripe, Card } from 'nativescript-stripe';
import { isAndroid, isIOS } from "tns-core-modules/platform";
export default {
    components: {},
    filters: {
        dollars: num => `$${num / 100}`
    },
    data() {
        return {
            buyer: {},
            stripeObj: null,
        };
    },
    props: {
        item: {
            type: Object,
            required: true
        }
    },
    computed: {
        total() {
            return this.item.price
        },
    },
    methods: {
        seeDetails(item) {
            this.$navigateTo(ItemDetail, { props: { item: item } });
        },
        submitPayment() {
            let that = this
            let cardobj
            let ccview = this.$refs.ccview.nativeView
            let myCallback = function getPaymentMethod(err, pm) {
                if (pm) {
                    return that.submitStripePayment(pm.id, that.buyer.email, that.total);
                } else if (err) {
                    console.log(err);
                }
            }
            if (isAndroid) {
                let newcard = ccview.android.getCard() //null if invalid
                if (newcard && newcard.validateCard()) {
                    cardobj = new Card(newcard.getNumber().toString(), Number(newcard.getExpMonth()), Number(newcard.getExpYear()), newcard.getCVC().toString())
                    this.stripeObj.createToken(cardobj, (error, token) => {
                        if (!error) {
                            that.submitStripePayment(token.id, that.buyer.email, that.total).then(() => {
                                alert("Payment sent").then(() => {
                                    that.$navigateBack()
                                })
                            }).catch(err => {
                                alert("Sorry, we were unable to reach our payment server. Try again later")
                            })
                        } else {
                            console.log("Error creating token!")
                            console.log(error);
                            alert("Sorry, we were unable to reach our payment server. Try again later")
                        }
                    })
                } else {
                    console.log("INVALID card")
                    alert("Sorry, credit card is not valid")
                }
            } else if ((isIOS && ccview.ios && ccview.ios.isValid)) {
                cardobj = new Card(ccview.ios.cardNumber.toString(), ccview.ios.expirationMonth, ccview.ios.expirationYear, ccview.ios.cvc.toString())
                this.stripeObj.createToken(cardobj, (error, token) => {
                    if (!error) {
                        that.submitStripePayment(token.id, that.buyer.email, that.total).then(() => {
                            alert("Payment sent").then(() => {
                                that.$navigateBack()
                            })
                        }).catch(err => {
                            alert("Sorry, we were unable to reach our payment server. Try again later")
                        })
                    } else {
                        console.log("Error creating token!")
                        console.log(error);
                        alert("Sorry, we were unable to reach our payment server. Try again later")
                    }
                })
            } else {
                alert("Sorry, credit card is not valid")
            }
        },
        submitStripePayment(token, email, amount) {
            let charge = {};
            return firebase.firestore.collection("Payments").add({
                email: email,
                amount: amount,
                token: token,
                charge: charge,
                createDate: new Date()
            }).then(documentRef => {
                console.log(`Payment Token added with auto-generated ID: ${documentRef.id}`);
                return Promise.resolve(documentRef);
            }).catch(
                err => {
                    return Promise.reject(err);
                });
        },
    },
    created() {
        this.stripeObj = new Stripe('YOUR_STRIPE_PUBLIC_TEST_KEY'); //public test key
    }
};
</script>

<style scoped>
.card {
    background-color: #fff;
    color: #4d4d4d;
    margin: 15;
}

.card-layout {
    padding: 20;
}

.line {
    background-color: #cecece;
    height: 2;
    margin: 0;
    padding: 4;
}

.input {
    font-size: 18;
    placeholder-color: #a8a8a8;
}

.buy-button {
    height: 30;
    background-color: rgb(51, 51, 206);
    color: white;
    border-radius: 5;
    font-size: 20;
    font-weight: 600;
}

.cancel-button {
    height: 30;
    background-color: rgb(179, 31, 31);
    color: white;
    border-radius: 5;
    font-size: 20;
    font-weight: 600;
}
</style>

Вы увидите, что мы используем виджет кредитной карты плагина в верхней части страницы, за которым следует обычная информация о пользователе, которая будет собираться для покупки. Этот виджет предоставляет визуальные подсказки на основе введенной информации о кредитной карте, а также имеет функцию проверки правильности формата номера кредитной карты. Когда пользователь желает приобрести товар, приложение проверит информацию о кредитной карте, создаст новую запись в коллекции Payments в Firestore и включит токен оплаты, сгенерированный на основе информации о кредитной карте пользователя и вашего открытого ключа Stripe. Убедитесь, что вы используете тестовые ключи на данный момент, чтобы вы могли протестировать процесс оплаты с помощью одной из тестовых карт для Stripe. Если все работает правильно, при нажатии кнопки Купить для элемента вы должны увидеть следующий экран:

Введите данные одной из тестовых кредитных карт и отправьте запрос на покупку. Теперь вы можете проверить свою базу данных Firebase Console Firestore и увидеть новую запись о покупке, сделанную приложением приложением, и обновленную подробными сведениями о расходах после завершения обработки облачной функцией:

Если это не помогло, вы можете проверить журналы Cloud Function, чтобы убедиться, что обработка сервера завершилась без ошибок. Если проблем нет, проверьте панель инструментов Stripe Test Dashboard, и вы должны увидеть платеж.

Выполнено!

Это все для этого поста. Если вы хотите скачать окончательные исходные файлы, вы можете найти их на Github.

Первоначально опубликовано на https://blog.angelengineering.com 24 января 2020 г.