Nhảy tới nội dung

Xây dựng Shopping Template cùng Tini App (Phần 1)

· Một phút để đọc

Mục lục

A. Giới thiệu

  • I. Shopping Template là gì ?
  • II. Chức năng
  • III. Cấu trúc dự án

B. Bắt tay vào việc

  • I. Setup project
  • II. Xây dựng Home page
    1. Phân tích layout
    2. Component banners
    3. Component section-title
    4. Component product
    5. Component product-section
    6. Component category
    7. Component category-section
    8. Component category-carousel
    9. Tổng thể
    10. Mock data và API

C. Tạm kết


🏁 A. Giới thiệu

I. Shopping Template là gì ?

Shopping Template là open souce template hỗ trợ lập trình viên xây dựng một ứng dụng mua sắm đơn giản bằng nền tảng Tini App. Hôm nay, chúng ta sẽ cùng nhau xây dựng lại template này từ đầu.

II. Chức năng

  • Xem thông tin cơ bản shop
  • Banners quảng cáo
  • Danh mục sản phẩm
  • Bộ sưu tập sản phẩm
  • Thông tin chi tiết sản phẩm
  • Sắp xếp và lọc sản phẩm
  • Tìm kiếm sản phẩm
  • Quản lý giỏ hàng
  • Thanh toán (mock)
  • Danh sách orders
  • Thông tin account

Bạn có thể quét mã QR hoặc click vào link này để trải nghiệm demo:

QR code

III. Cấu trúc dự án

src

└── assets
│ └───icons
│ └───images

└── components
│ └───banners
│ └───product-section
│ └───category-section
│ └───...

└── pages
│ └───home
│ └───category
│ └───search
│ └───...

└── services

└── utils

└── services

└── app.js
└── app.json
└── app.tcss
└── package.json

🚀 B. Bắt tay vào việc

I. Setup project

  • Tạo một project Tini App mới (Xem thêm).
  • Để sử dụng các Advance Component như textfield, stepper,... chúng ra sẽ cài đặt tini-ui (Xem thêm).
  • Để style các component một các đơn giản chúng ta có thể sử dụng tini-style (tini-style đã được built-in tron tini-ui, bạn chỉ cần import trong file app.tcss (Xem thêm).
  • Thêm query-string để truyền query giữ các pages dễ dàng hơn (Xem thêm).
package.json
{
"name": "miniapp-tini-shop",
"version": "1.0.1",
"tiki": {
"appIdentifier": "vn.tiki.tinishop",
"buildNumber": 1
},
"dependencies": {
"@tiki.vn/tini-ui": "0.10.0",
"query-string": "^7.0.1"
}
}

II. Xây dựng Home page

home page

1. Phân tích layout

Break nhỏ layout này ta sẽ được các components:

2. Component banners

Ý tưởng của banners khá đơn giản, chúng ta sẽ có nhận vào 1 mảng banners là array các url image, sau đó dùng component được built-in sẵn trong framework là carousel để show các images ấy dưới dạng carousel.

📌 Xem thêm carouselimage

components/banners/index.js
Component({
props: {
banners: []
}
});
components/banners/index.txml
<carousel
tiki:else
autoplay
indicator-dots
circular
class="banners"
>
<carousel-item
tiki:for="{{banners}}"
tiki:key="id"
>
<image
class="w-full h-full"
src="{{item.image}}"
mode="widthFix"
/>
</carousel-item>
</carousel>

📌 Xem souce code tại đây

3. Component section-title

Chúng ta có thể dễ dàng nhận ra hình ảnh này được lặp đi lặp lại nhiều lần trong home page, thế nên tại sao chúng ta lại không component hoá nó nhỉ, đây cũng là một component khá dễ để chúng ta làm quen với các class được cung cấp bởi tini-style.

ClassCSS
.flexdisplay: flex;
.justify-betweenjustify-content: space-between;
.items-centeralign-items: center;
.font-boldfont-weight: 700;
.text-mediumfont-size: 16px;

📌 Các giá trị css như 700 và 16px là giá trị mặc định, bạn có thể customize các giá trị này thông qua việc chỉnh sửa css token 📌 Xem thêm tini-style

📌 Xem thêm texticon

components/section-title/index.js
Component({
props: {
title: '',
onTapActionButton: () => {}
},

methods: {
_onTapActionButton() {
this.props.onTapActionButton();
}
}
});
components/section-title/index.txml
<view class="flex justify-between items-center mt-4x-small mb-medium">
<text class="font-bold text-medium">
{{title}}
</text>
<view
class="flex items-center"
onTap="_onTapActionButton"
>
<text class="text-brand mr-2x-small">View more</text>
<icon type="arrow_right" color="#1A94FF"/>
</view>
</view>


📌 Xem souce code tại đây

4. Component product

Ở component product này phần layout cũng khá dễ, chúng ta chỉ cần sử dụng một vài class của tini-style như flexboxspacing là có thể hoàn thành cơ bản layout này.

Mình xin giới thiệu cái đặc biệt ở component này chính là phần format giá tiền. Ví dụ data chúng ta nhận được là price: 3569000, làm sao để format con số này thành 3.569.000 đ và hiển thị trong txml.

Có nhiều cách để làm việc này, tuy nhiên trong khuôn khổ bài này mình xin chia sẻ với các bạn cách mình hay sử dụng nhất đó là dùng sjs .

📌 Xem thêm sjs

Bước 1: Tạo file common.sjs
utils/common.sjs
// Nhiệm vụ của function này là convert number 1000 --> 1.000 đ

export const moneyFormatter = (number, currency = ' ₫') => {
return parseInt(number).toLocaleString('vi-VN') + currency;
};
Bước 2: Import function trên vào file index.txml của product
components/product/index.txml
<import-sjs from="../../utils/common.sjs" name="{moneyFormatter}"></import-sjs>
Bước 2: Sử dụng function
components/product/index.js
{
{
moneyFormatter(product.price);
}
}

📌 Xem souce code tại đây

5. Component product-section

Ở home page chúng ta có 2 loại product-section:

  • Loại vertical có dạng grid 2 cột, scroll xuống để xem thêm sản phẩm

  • Loại horizontal có dạng slide, vuốt sang trái để xem thêm sản phầm

Chúng ta sẽ có một component với các props sau:

  • type sẽ có giá trị là horizontal hoặc vertical để phân xác định dạng hiển thị
  • products là array chứa các sản phẩm
  • onTapProduct là callback sẽ được gọi khi nhấn vào product
components/product-section/index.js
Component({
props: {
type: 'vertical',
products: [],
onTapProduct: () => {}
},

methods: {
_onTapProduct(product) {
this.props.onTapProduct(product);
}
}
});

Ở file .txml, chúng ta sẽ dùng template để tách biệt txml của 2 dạng vertical và horizontal

📌 Xem thêm template

components/product-section/index.txml
<view class="{{className}}">
<template name="vertical">
<view class="product-section-vertical">
<block
tiki:for="{{products}}"
tiki:key="id"
>
<product product="{{item}}" onTapProduct="_onTapProduct"/>
</block>
</view>
</template>

<template name="horizontal">
<view class="product-section-horizontal hide-scroll-bar">
<block
tiki:for="{{products}}"
tiki:key="id"
>
<product product="{{item}}" onTapProduct="_onTapProduct"/>
</block>
</view>
</template>

<template
is="{{type}}"
data="{{isLoading, products}}"
/>
</view>

Ở file .tcss:

  • Với dạng vertical ta sẽ dùng grid và set 2 cột.
  • Với dạng horizontal ta sẽ dùng flex và cho overflow-x auto.
components/product-section/index.tcss
.product-section-vertical {
display: grid;
grid-template-columns: 1fr 1fr;
}

.product-section-horizontal {
display: flex;
overflow-x: auto;
}

📌 Xem souce code tại đây

6. Component category

Tương tự component product, đây là một component đơn giản chúng ta chỉ cần sử dụng một vài class của tini-style như flexboxspacing là có thể hoàn thành cơ bản layout này.

📌 Xem souce code tại đây

7. Component category-section

Component category-section sẽ có layout giống product-section dạng vertical, chỉ khác là category-section sẽ có 4 cột thay vì 2 và component con là category thay vì product.

components/category-section/index.tcss
.product-section-vertical {
display: grid;
grid-template-columns: repeat(4, 1fr);
}

📌 Xem souce code tại đây

category-carousel

Thoạt nhìn qua category-carousel có vẻ giống category-section. Tuy nhiên bản chất category-carousel sẽ gồm một hoặc nhiều category-section dưới dạng carousel.

Chúng ta sẽ có một component với các props và method sau sau:

  • categories là array chứa các category con
  • onTapCategory là callback sẽ được gọi khi nhấn vào category
  • activatedCategory để xác định index của category-section đang được active
  • onCategoryChange sẽ được gọi khi chúng ta vuốt và set lại giá trị activatedCategory
components/category-carousel/index.js
Component({
props: {
categories: [],
onTapCategory: () => {},
activatedCategory: 0
},

methods: {
_onTapCategory(category) {
this.props.onTapCategory(category);
},

onCategoryChange(event) {
this.setData({
activatedCategory: event.detail.current
});
}
}
});
components/category-carousel/index.txml
<carousel
indicator-dots="{{categories.length > 1}}"
onChange="onCategoryChange"
>
<carousel-item tiki:for="{{categories}}">
<category-section
className="category-carousel {{index === activatedCategory ? 'category-carousel-active' : index < activatedCategory ? 'category-carousel-prev' : 'category-carousel-next'}} {{index === categories.length - 1 ? 'category-carousel-last' : ''}}"
categories="{{item}}"
onTapCategory="_onTapCategory"
/>
</carousel-item>
</carousel>

Ứng với mỗi vị trí prev, active, next, last ta sẽ có css khác nhau để tạo ra hiệu ứng như video ở trên.

components/category-carousel/index.tcss
.category-carousel {
transition: ease-in-out 0.3s;
}

.category-carousel-prev {
transform: translateX(-22px);
}

.category-carousel-active,
.category-carousel-next {
transform: translateX(22px);
}

.category-carousel-active.category-carousel-last {
padding: 0 8px;
transform: unset;
}

📌 Xem souce code tại đây

9. Tổng thể

Như vậy chúng ta đã đi qua tất cả các component ở home page, bây giờ ta có thể ghép chúng lại với nhau:

pages/home/index.txml
<view tiki:else>
<banners
class="home-banners"
banners="{{banners}}"
/>

<view class="home-section">
<section-title
title="Hot deals"
/>
<product-section
type="horizontal"
className="home-hot-deals"
products="{{hotDealProducts}}"
onTapProduct="onTapProduct"
/>
</view>

<view class="home-section home-category">
<section-title
title="Category"
onTapActionButton="goToCategory"
/>
<category-carousel
categories="{{categories}}"
onTapCategory="goToCategoryDetail"
/>
</view>

<view class="home-section">
<section-title
title="Featured"
/>
<product-section
products="{{featuredProducts}}"
onTapProduct="onTapProduct"
/>
</view>

<view class="home-section">
<section-title
title="New"
/>
<product-section
products="{{newProducts}}"
onTapProduct="onTapProduct"
/>
</view>
</view>

Khai báo tên page

pages/home/index.json
{
"defaultTitle": "Shop Name",
"usingComponents": {
"banners": "components/banners/index",
"section-title": "components/section-title/index",
"product-section": "components/product-section/index",
"category-carousel": "components/category-carousel/index"
}
}

Thêm icon giỏ hàng

pages/home/index.js
//...
async onReady() {
my.addIconsToNavigationBar({
icons: [
{
image: image,
width: 48,
height: 48,
},
],
padding: 8,
});
},

Thêm tab bar bằng cách khai bảo ở app.json:

app.json
{
"window": {
"defaultTitle": "Tini shop"
},
"pages": ["pages/home/index"],
"tabBar": {
"borderTopActiveColor": "#1A94FF",
"borderTopColor": "#EBEBF0",
"items": [
{
"name": "Home",
"pagePath": "pages/home/index",
"icon": "/assets/icons/tab-home.png",
"activeIcon": "assets/icons/tab-home-active.png"
}
]
}
}

Và chúng ta sẽ có kết quả như hình:

10. Mock data và API

Bạn sẽ thắc mắc là data đâu để render như vậy. Câu trả lời là chúng ta sẽ xây dựng mock data và lấy chúng thông qua API của Github. Vì mình đã publish chúng nên bạn có thể sử dụng chung mock data với mình mà không phải xây dựng lại từ đầu. Ví dụ bạn muốn lấy danh sách sản phẩm mới, bạn có thể gọi tới API sau: https://raw.githubusercontent.com/tikivn/miniapp-getting-started/main/shop/src/services/mock/new-products.json

Ở Tini App, bạn không cần và không sử dụng được các package khác để tạo một request API, thay vào đó Tini App đã cung cấp cho bạn một jsapi là my.request.

📌 Xem thêm my.request

my.request có dạng callback nên để thuận tiện mình sẽ wrap nó lại thành một async function:

services/request.js
export const request = async ({
path,
method = 'GET',
headers = {},
data
}) => {
return new Promise((resolve, reject) => {
my.request({
url: `${BASE_URL}/${path}.json`,
headers: {
'Content-Type': 'application/json',
...headers
},
method,
data,
success: (res) => {
resolve(res);
},
fail: (err) => {
reject(err);
}
});
});
};

Cài đặt các API gọi đến từng end-point tương ứng:

services/index.js
export const getShopInfoAPI = () => {
return request({ path: '/shop' });
};

export const getCategoriesAPI = () => {
return request({ path: '/categories' });
};

export const getFeaturedProductsAPI = () => {
return request({ path: '/featured-products' });
};

export const getNewProductsAPI = () => {
return request({ path: '/new-products' });
};

Và ở page, mình sẽ gọi các API này ở life cycle method onLoad() và dùng setData() để gán các giá trị mới nhận được vào data đồng thời trigger re-render.

📌 Xem thêm Page và setData 📌 Xem thêm Page's life cycle

pages/home/index.js
Page({
data: {
shop: {},
categories: [],
featuredProducts: [],
newProducts: [],
banners: [],
hotDealProducts: []
},

async loadData() {
try {
const [
shop,
categories,
featuredProducts,
newProducts,
banners,
hotDealProducts
] = await Promise.all([
getShopInfoAPI(),
getCategoriesAPI(),
getFeaturedProductsAPI(),
getNewProductsAPI(),
getBannersAPI(),
getHotDealProductsAPI()
]);

this.setData({
shop,
featuredProducts,
newProducts,
banners,
hotDealProducts,
categories: group(categories, 8)
});
} catch {
this.setData({
isLoading: false
});
}
},

async onReady() {
this.loadData();
}
});

📌 Xem souce code tại đây


🔚 C. Tạm kết

Chúng ta đã đi qua các phần:

  • Shopping Template là gì
  • Chức năng và cấu trúc dự án
  • Cách khởi tạo project Tini App
  • Xây dựng Home page:
    • Phân tích và break layout
    • Xây dựng các component
    • Data và gọi API

Rất cảm ơn bạn và cũng xin chúc mừng bạn đã cùng mình tìm hiểu xây dựng từ đầu một ứng dụng Tini App thực sự. Vì bài viết đã dài nên mình xin kết thúc phần 1 ở đây. Xin hẹn gặp lại các bạn ở phần 2 và cùng nhau xây dựng những pages tiếp theo Chúc các bạn có thời gian bổ ích và vui vẻ với Tini App. 🎉