diff --git a/__manifest__.py b/__manifest__.py
index 24c4dac..37273c6 100644
--- a/__manifest__.py
+++ b/__manifest__.py
@@ -1,14 +1,19 @@
# -*- coding: utf-8 -*-
{
'name': "Mapan Loyalty Push Notifications",
- 'summary': "Send push notifications to the Loyalty Flutter App",
+ 'summary': "Mobile App CMS: Push notifications, carousel, promos and app configuration",
'description': """
- Integrates Odoo with Firebase Cloud Messaging (FCM) to send push notifications directly
- to customers' Android devices. Maps FCM tokens to res_partner records.
+ Content Management System for the Mie Mapan Loyalty Flutter App.
+ Features:
+ - Send push notifications to loyalty app users
+ - Manage carousel banner slides (home screen)
+ - Manage promo highlight cards (home screen, with rich text detail)
+ - Configure App Settings (About Us URL, Contact Us URL)
+ - API endpoints for Flutter app: notifications, CMS content, order history, branches
""",
'author': "Suherdy Yacob",
'category': 'Marketing',
- 'version': '1.0',
+ 'version': '1.1',
'depends': ['base', 'loyalty', 'pos_loyalty', 'sale_loyalty'],
'data': [
'security/mapan_loyalty_push_security.xml',
@@ -16,6 +21,9 @@
'wizard/push_wizard_views.xml',
'views/res_partner_views.xml',
'views/app_notification_views.xml',
+ 'views/app_carousel_views.xml',
+ 'views/app_promo_views.xml',
+ 'views/app_cms_config_views.xml',
],
'installable': True,
'application': False,
diff --git a/controllers/main.py b/controllers/main.py
index 751f65f..691a63e 100644
--- a/controllers/main.py
+++ b/controllers/main.py
@@ -2,6 +2,7 @@
from odoo import http
from odoo.http import request
+
def normalize_phone_search(phone):
if not phone:
return []
@@ -20,6 +21,7 @@ def normalize_phone_search(phone):
candidates.append('62' + digits)
return list(set(candidates))
+
class AppNotificationController(http.Controller):
@http.route('/api/loyalty/fetch_notifications', type='jsonrpc', auth='user', methods=['POST'], csrf=False)
@@ -28,6 +30,7 @@ class AppNotificationController(http.Controller):
Endpoint for the Flutter app Background Task and In-App notification center.
Returns image_128 (base64 thumbnail) for list display.
The Flutter detail screen loads the full image via /web/image/ with session cookie.
+ Body is now HTML from the rich text editor — returned as-is.
"""
import base64 as b64mod
user = request.env.user
@@ -58,21 +61,144 @@ class AppNotificationController(http.Controller):
elif not img:
notif['image_128'] = None
notif['has_image'] = bool(notif['image_128'])
+ # body is HTML string — pass through as-is
+ if notif.get('body') is False:
+ notif['body'] = ''
return {
'status': 'success',
'data': notifications
}
+ @http.route('/api/loyalty/cms_content', type='jsonrpc', auth='public', methods=['POST'], csrf=False)
+ def fetch_cms_content(self, **kw):
+ """
+ Public endpoint to fetch carousel slides and promo highlights for the Flutter app home screen.
+ """
+ import base64 as b64mod
+ from datetime import date
+
+ today = date.today()
+
+ # --- Carousel Slides ---
+ carousel_domain = [('is_active', '=', True)]
+ carousel_items = request.env['mapan.app.carousel'].sudo().search_read(
+ carousel_domain,
+ ['id', 'name', 'image', 'image_url', 'link_url', 'sequence'],
+ order='sequence, id'
+ )
+ for item in carousel_items:
+ img = item.get('image')
+ if img and isinstance(img, bytes):
+ item['image'] = img.decode('utf-8')
+ elif not img:
+ item['image'] = None
+
+ # --- Promo Highlights ---
+ promo_domain = [('is_active', '=', True)]
+ promos = request.env['mapan.app.promo'].sudo().search_read(
+ promo_domain,
+ ['id', 'name', 'body', 'image_128', 'date_start', 'date_end', 'sequence'],
+ order='sequence, id'
+ )
+ active_promos = []
+ for promo in promos:
+ # Filter by validity date client-side after fetch
+ ds = promo.get('date_start')
+ de = promo.get('date_end')
+ if ds and ds > today:
+ continue
+ if de and de < today:
+ continue
+ img = promo.get('image_128')
+ if img and isinstance(img, bytes):
+ promo['image_128'] = img.decode('utf-8')
+ elif not img:
+ promo['image_128'] = None
+ # Convert date objects to string
+ promo['date_start'] = str(promo['date_start']) if promo.get('date_start') else None
+ promo['date_end'] = str(promo['date_end']) if promo.get('date_end') else None
+ if promo.get('body') is False:
+ promo['body'] = ''
+ active_promos.append(promo)
+
+ return {
+ 'status': 'success',
+ 'carousel': carousel_items,
+ 'promos': active_promos,
+ }
+
+ @http.route('/api/loyalty/app_config', type='jsonrpc', auth='public', methods=['POST'], csrf=False)
+ def fetch_app_config(self, **kw):
+ """
+ Public endpoint to fetch app configuration (About Us URL, Contact Us URL).
+ """
+ config = request.env['mapan.app.config'].sudo().search([], limit=1)
+ if not config:
+ config = request.env['mapan.app.config'].sudo().create({'name': 'App Configuration'})
+ return {
+ 'status': 'success',
+ 'about_us_url': config.about_us_url or '',
+ 'contact_us_url': config.contact_us_url or '',
+ }
+
+ @http.route('/api/loyalty/order_history', type='jsonrpc', auth='user', methods=['POST'], csrf=False)
+ def fetch_order_history(self, **kw):
+ """
+ Authenticated endpoint to fetch loyalty point history for the current user.
+ Queries loyalty.history which tracks earn/spend events.
+ Positive points = earned, negative points = spent.
+ """
+ user = request.env.user
+ partner = user.partner_id
+
+ # Get all loyalty cards for this partner
+ cards = request.env['loyalty.card'].sudo().search([('partner_id', '=', partner.id)])
+ card_ids = cards.ids
+
+ if not card_ids:
+ return {'status': 'success', 'data': []}
+
+ history_records = request.env['loyalty.history'].sudo().search_read(
+ [('card_id', 'in', card_ids)],
+ ['id', 'card_id', 'points', 'date', 'order_id'],
+ order='date desc',
+ limit=100
+ )
+
+ result = []
+ for rec in history_records:
+ points = rec.get('points') or 0
+ point_type = 'earn' if points >= 0 else 'spend'
+
+ order_ref = ''
+ order_id = rec.get('order_id')
+ if order_id and isinstance(order_id, (list, tuple)) and len(order_id) > 1:
+ order_ref = str(order_id[1])
+ elif order_id:
+ order_ref = str(order_id)
+
+ result.append({
+ 'id': rec['id'],
+ 'date': str(rec.get('date') or ''),
+ 'points': round(float(points), 2),
+ 'type': point_type,
+ 'order_ref': order_ref,
+ 'card_id': rec['card_id'][0] if isinstance(rec.get('card_id'), (list, tuple)) else rec.get('card_id'),
+ })
+
+ return {'status': 'success', 'data': result}
+
@http.route('/api/loyalty/branches', type='jsonrpc', auth='public', methods=['POST'], csrf=False)
def fetch_branches(self, **kw):
"""
Public endpoint for the Flutter app to get branches without exposing API keys.
+ Includes latitude/longitude for geolocation-based distance sorting on the client.
"""
try:
branches = request.env['res.company'].sudo().search_read(
[('parent_id', '!=', False)],
- ['name', 'street', 'city', 'phone'],
+ ['name', 'street', 'city', 'phone', 'partner_latitude', 'partner_longitude'],
limit=50
)
return {'status': 'success', 'data': branches}
@@ -88,10 +214,10 @@ class AppNotificationController(http.Controller):
phone = kw.get('phone')
phone_or_email = kw.get('phone_or_email')
otp_type = kw.get('type')
-
+
if not otp_type:
return {'status': 'error', 'message': 'OTP type is required.'}
-
+
if otp_type == 'signup':
if not email or not phone:
return {'status': 'error', 'message': 'Email and Phone are required for sign-up.'}
@@ -102,13 +228,13 @@ class AppNotificationController(http.Controller):
], limit=1)
if existing_partner:
return {'status': 'error', 'message': 'A member with this phone number is already registered.'}
-
+
existing_user = request.env['res.users'].sudo().with_context(active_test=False).search([
'|', ('login', '=', phone), ('login', 'in', candidates), ('email', '=', email)
], limit=1)
if existing_user:
return {'status': 'error', 'message': 'A user with this phone number or email already exists.'}
-
+
elif otp_type == 'activation':
if not email or not phone:
return {'status': 'error', 'message': 'Email and Phone are required for activation.'}
@@ -119,21 +245,21 @@ class AppNotificationController(http.Controller):
], limit=1)
if not partner:
return {'status': 'error', 'message': 'Member with this phone number not found. Please contact support or register.'}
-
+
# Check if user already active
user = request.env['res.users'].sudo().with_context(active_test=False).search([('partner_id', '=', partner.id)], limit=1)
if user and user.active:
return {'status': 'error', 'message': 'Account is already active. Please login or reset password.'}
-
+
# Check if email is already taken by another active user
existing_email_user = request.env['res.users'].sudo().search([('email', '=', email), ('partner_id', '!=', partner.id)], limit=1)
if existing_email_user:
return {'status': 'error', 'message': 'This email address is already registered to another account.'}
-
+
elif otp_type == 'reset_password':
if not phone_or_email:
return {'status': 'error', 'message': 'Phone number or Email is required.'}
-
+
# Find user
user = None
if '@' in phone_or_email:
@@ -147,23 +273,23 @@ class AppNotificationController(http.Controller):
], limit=1)
if partner:
user = request.env['res.users'].sudo().search([('partner_id', '=', partner.id)], limit=1)
-
+
if not user:
return {'status': 'error', 'message': 'User account not found.'}
-
+
email = user.email
if not email or '@miemapan.com' in email or '@' not in email:
return {'status': 'error', 'message': 'Your account does not have a valid email set. Please contact support.'}
-
+
phone = user.login
-
+
try:
request.env['loyalty.verification.otp'].sudo().generate_otp(email=email, phone=phone, otp_type=otp_type)
# Mask email for privacy response
parts = email.split('@')
masked_email = parts[0][:2] + '*' * (len(parts[0]) - 2) + '@' + parts[1] if len(parts[0]) > 2 else email
return {
- 'status': 'success',
+ 'status': 'success',
'message': f'Verification code sent to {masked_email}.',
'email': email
}
@@ -180,35 +306,35 @@ class AppNotificationController(http.Controller):
password = kw.get('password')
birth_date = kw.get('birth_date')
otp = kw.get('otp')
-
+
if not phone or not password or not email or not otp:
return {'status': 'error', 'message': 'Phone, Email, Password, and Verification Code (OTP) are required.'}
-
+
# Verify OTP first
otp_verified = request.env['loyalty.verification.otp'].sudo().verify_otp(
otp_code=otp, email=email, phone=phone, otp_type='activation'
)
if not otp_verified:
return {'status': 'error', 'message': 'Invalid or expired verification code.'}
-
+
candidates = normalize_phone_search(phone)
partner = request.env['res.partner'].sudo().search([
'|', ('phone', 'in', candidates), ('phone_sanitized', 'in', candidates)
], limit=1)
-
+
if not partner:
return {'status': 'error', 'message': 'Member with this phone number not found. Please contact support or register.'}
-
+
# Verify birth date if set on the partner
if partner.birth_date:
if not birth_date:
return {'status': 'error', 'message': 'Birth Date is required for verification.'}
if str(partner.birth_date) != str(birth_date):
return {'status': 'error', 'message': 'Birth Date does not match our records.'}
-
+
# Update email on partner
partner.sudo().write({'email': email})
-
+
# Find or create user
user = request.env['res.users'].sudo().with_context(active_test=False).search([('partner_id', '=', partner.id)], limit=1)
if user:
@@ -228,7 +354,7 @@ class AppNotificationController(http.Controller):
existing_user = request.env['res.users'].sudo().with_context(active_test=False).search([('login', '=', phone)], limit=1)
if existing_user:
return {'status': 'error', 'message': 'A user with this phone number login already exists.'}
-
+
try:
user = portal_template.sudo().with_context(no_reset_password=True).copy({
'name': partner.name,
@@ -250,7 +376,7 @@ class AppNotificationController(http.Controller):
user.sudo().write({'group_ids': [(4, portal_group.id)]})
except Exception as e:
return {'status': 'error', 'message': f'Failed to create user: {str(e)}'}
-
+
return {'status': 'success', 'message': 'Account activated successfully. You can now login.'}
@http.route('/api/loyalty/signup_member', type='jsonrpc', auth='public', methods=['POST'], csrf=False)
@@ -265,31 +391,31 @@ class AppNotificationController(http.Controller):
gender = kw.get('gender')
password = kw.get('password')
otp = kw.get('otp')
-
+
if not name or not phone or not email or not password or not otp:
return {'status': 'error', 'message': 'Name, Phone, Email, Password, and Verification Code (OTP) are required.'}
-
+
# Verify OTP first
otp_verified = request.env['loyalty.verification.otp'].sudo().verify_otp(
otp_code=otp, email=email, phone=phone, otp_type='signup'
)
if not otp_verified:
return {'status': 'error', 'message': 'Invalid or expired verification code.'}
-
+
candidates = normalize_phone_search(phone)
-
+
existing_partner = request.env['res.partner'].sudo().search([
'|', ('phone', 'in', candidates), ('phone_sanitized', 'in', candidates)
], limit=1)
if existing_partner:
return {'status': 'error', 'message': 'A member with this phone number is already registered.'}
-
+
existing_user = request.env['res.users'].sudo().with_context(active_test=False).search([
'|', ('login', '=', phone), ('login', 'in', candidates), ('email', '=', email)
], limit=1)
if existing_user:
return {'status': 'error', 'message': 'A user with this phone number or email already exists.'}
-
+
try:
partner_vals = {
'name': name,
@@ -300,7 +426,7 @@ class AppNotificationController(http.Controller):
'company_id': request.env.company.id,
}
partner = request.env['res.partner'].sudo().create(partner_vals)
-
+
portal_template = request.env.ref('base.template_portal_user_id')
user = portal_template.sudo().with_context(no_reset_password=True).copy({
'name': partner.name,
@@ -320,7 +446,7 @@ class AppNotificationController(http.Controller):
portal_group = request.env.ref('base.group_portal')
if portal_group not in user.group_ids:
user.sudo().write({'group_ids': [(4, portal_group.id)]})
-
+
return {'status': 'success', 'message': 'Account registered successfully.'}
except Exception as e:
return {'status': 'error', 'message': f'Registration failed: {str(e)}'}
@@ -333,10 +459,10 @@ class AppNotificationController(http.Controller):
phone_or_email = kw.get('phone_or_email')
otp = kw.get('otp')
new_password = kw.get('password')
-
+
if not phone_or_email or not otp or not new_password:
return {'status': 'error', 'message': 'Phone/Email, OTP, and New Password are required.'}
-
+
# Find user
user = None
if '@' in phone_or_email:
@@ -350,17 +476,17 @@ class AppNotificationController(http.Controller):
], limit=1)
if partner:
user = request.env['res.users'].sudo().search([('partner_id', '=', partner.id)], limit=1)
-
+
if not user:
return {'status': 'error', 'message': 'User account not found.'}
-
+
# Verify OTP
otp_verified = request.env['loyalty.verification.otp'].sudo().verify_otp(
otp_code=otp, email=user.email, phone=user.login, otp_type='reset_password'
)
if not otp_verified:
return {'status': 'error', 'message': 'Invalid or expired verification code.'}
-
+
try:
user.sudo().write({
'password': new_password,
@@ -377,19 +503,19 @@ class AppNotificationController(http.Controller):
"""
user = request.env.user
password = kw.get('password')
-
+
if not password:
return {'status': 'error', 'message': 'Password is required.'}
-
+
try:
user.with_user(user)._check_credentials({'type': 'password', 'password': password}, {'interactive': True})
except Exception:
return {'status': 'error', 'message': 'Incorrect password.'}
-
+
partner = user.partner_id
-
+
user.sudo().write({'active': False})
-
+
partner.sudo().write({
'name': f'Deleted Member {partner.id}',
'phone': False,
@@ -398,10 +524,10 @@ class AppNotificationController(http.Controller):
'gender': False,
'active': False,
})
-
+
partner.loyalty_card_ids.sudo().write({
'active': False,
'points': 0.0
})
-
+
return {'status': 'success', 'message': 'Account deleted successfully.'}
diff --git a/models/__init__.py b/models/__init__.py
index 44c88b8..d3eb7ca 100644
--- a/models/__init__.py
+++ b/models/__init__.py
@@ -1,7 +1,8 @@
from . import res_partner
from . import app_notification
+from . import app_carousel
+from . import app_promo
+from . import app_cms_config
from . import res_users
from . import loyalty_card
from . import loyalty_verification_otp
-
-
diff --git a/models/app_carousel.py b/models/app_carousel.py
new file mode 100644
index 0000000..70c4d7d
--- /dev/null
+++ b/models/app_carousel.py
@@ -0,0 +1,32 @@
+# -*- coding: utf-8 -*-
+from odoo import models, fields
+
+
+class AppCarousel(models.Model):
+ """Carousel slides managed from the Odoo backend, displayed on the Flutter app home screen."""
+ _name = 'mapan.app.carousel'
+ _description = 'Mobile App Carousel Slide'
+ _order = 'sequence, id'
+
+ name = fields.Char(string='Slide Title', required=True)
+ sequence = fields.Integer(string='Sequence', default=10)
+ is_active = fields.Boolean(string='Active', default=True)
+
+ # Image: uploaded from Odoo backend
+ image = fields.Image(
+ string='Banner Image',
+ max_width=1920,
+ max_height=720,
+ help='Banner image for the carousel slide (recommended ratio 16:6).'
+ )
+
+ # External image URL (alternative to uploaded image)
+ image_url = fields.Char(
+ string='External Image URL',
+ help='If set and no image is uploaded, the app will load the image from this URL.'
+ )
+
+ link_url = fields.Char(
+ string='Tap URL',
+ help='URL to open when the user taps this carousel slide. Leave empty for no action.'
+ )
diff --git a/models/app_cms_config.py b/models/app_cms_config.py
new file mode 100644
index 0000000..347d5da
--- /dev/null
+++ b/models/app_cms_config.py
@@ -0,0 +1,27 @@
+# -*- coding: utf-8 -*-
+from odoo import models, fields, api
+
+
+class AppCmsConfig(models.Model):
+ """Singleton model for Mobile App global configuration."""
+ _name = 'mapan.app.config'
+ _description = 'Mobile App Configuration'
+
+ name = fields.Char(default='App Configuration', readonly=True)
+
+ about_us_url = fields.Char(
+ string='About Us URL',
+ help='URL to open when user taps "About Us" in the app account menu.'
+ )
+ contact_us_url = fields.Char(
+ string='Contact Us URL',
+ help='URL to open when user taps "Contact Us" in the app account menu.'
+ )
+
+ @api.model
+ def get_config(self):
+ """Always return the single config record, creating it if it does not exist."""
+ config = self.search([], limit=1)
+ if not config:
+ config = self.create({'name': 'App Configuration'})
+ return config
diff --git a/models/app_notification.py b/models/app_notification.py
index f76a411..320bb24 100644
--- a/models/app_notification.py
+++ b/models/app_notification.py
@@ -6,7 +6,7 @@ class AppNotification(models.Model):
_order = 'create_date desc'
title = fields.Char(string='Notification Title', required=True)
- body = fields.Text(string='Notification Body', required=True)
+ body = fields.Html(string='Notification Body', required=True)
# Optional promotional image — Odoo auto-generates image_128 thumbnail
image = fields.Image(
diff --git a/models/app_promo.py b/models/app_promo.py
new file mode 100644
index 0000000..0fb7bb3
--- /dev/null
+++ b/models/app_promo.py
@@ -0,0 +1,42 @@
+# -*- coding: utf-8 -*-
+from odoo import models, fields
+
+
+class AppPromo(models.Model):
+ """Highlight Promo items managed from the Odoo backend.
+
+ Displayed as a horizontal scrollable row below the carousel on the Flutter app home screen.
+ When tapped, the app shows a detail screen with title, image, and the rich-text body.
+ """
+ _name = 'mapan.app.promo'
+ _description = 'Mobile App Promo Highlight'
+ _order = 'sequence, id'
+
+ name = fields.Char(string='Promo Title', required=True)
+ sequence = fields.Integer(string='Sequence', default=10)
+ is_active = fields.Boolean(string='Active', default=True)
+
+ # Rich text body (like the notification body)
+ body = fields.Html(
+ string='Promo Detail Content',
+ help='Rich text content shown when the user taps on this promo highlight.'
+ )
+
+ # Thumbnail image for the list/card view
+ image = fields.Image(
+ string='Promo Image',
+ max_width=800,
+ max_height=800,
+ help='Image shown on the promo card tile. Square format recommended.'
+ )
+ image_128 = fields.Image(
+ string='Thumbnail',
+ related='image',
+ max_width=128,
+ max_height=128,
+ store=True,
+ readonly=True,
+ )
+
+ date_start = fields.Date(string='Valid From', help='Leave empty for no start restriction.')
+ date_end = fields.Date(string='Valid Until', help='Leave empty for no expiry.')
diff --git a/security/ir.model.access.csv b/security/ir.model.access.csv
index 2cb7df4..db6be50 100644
--- a/security/ir.model.access.csv
+++ b/security/ir.model.access.csv
@@ -3,6 +3,15 @@ access_mapan_push_wizard_manager,mapan_push_wizard_manager,model_mapan_push_wiza
access_mapan_app_notification_user,mapan_app_notification_user,model_mapan_app_notification,mapan_loyalty_push.group_mapan_loyalty_push_user,1,0,0,0
access_mapan_app_notification_manager,mapan_app_notification_manager,model_mapan_app_notification,mapan_loyalty_push.group_mapan_loyalty_push_manager,1,1,1,1
access_mapan_app_notification_portal,mapan_app_notification_portal,model_mapan_app_notification,base.group_portal,1,0,0,0
+access_mapan_app_carousel_user,mapan_app_carousel_user,model_mapan_app_carousel,mapan_loyalty_push.group_mapan_loyalty_push_user,1,0,0,0
+access_mapan_app_carousel_manager,mapan_app_carousel_manager,model_mapan_app_carousel,mapan_loyalty_push.group_mapan_loyalty_push_manager,1,1,1,1
+access_mapan_app_carousel_portal,mapan_app_carousel_portal,model_mapan_app_carousel,base.group_portal,1,0,0,0
+access_mapan_app_promo_user,mapan_app_promo_user,model_mapan_app_promo,mapan_loyalty_push.group_mapan_loyalty_push_user,1,0,0,0
+access_mapan_app_promo_manager,mapan_app_promo_manager,model_mapan_app_promo,mapan_loyalty_push.group_mapan_loyalty_push_manager,1,1,1,1
+access_mapan_app_promo_portal,mapan_app_promo_portal,model_mapan_app_promo,base.group_portal,1,0,0,0
+access_mapan_app_config_user,mapan_app_config_user,model_mapan_app_config,mapan_loyalty_push.group_mapan_loyalty_push_user,1,0,0,0
+access_mapan_app_config_manager,mapan_app_config_manager,model_mapan_app_config,mapan_loyalty_push.group_mapan_loyalty_push_manager,1,1,1,1
+access_mapan_app_config_portal,mapan_app_config_portal,model_mapan_app_config,base.group_portal,1,0,0,0
access_loyalty_card_portal,loyalty.card.portal,loyalty.model_loyalty_card,base.group_portal,1,0,0,0
access_loyalty_program_portal,loyalty.program.portal,loyalty.model_loyalty_program,base.group_portal,1,0,0,0
access_loyalty_history_portal,loyalty.history.portal,loyalty.model_loyalty_history,base.group_portal,1,0,0,0
@@ -10,4 +19,3 @@ access_loyalty_reward_portal,loyalty.reward.portal,loyalty.model_loyalty_reward,
access_loyalty_rule_portal,loyalty.rule.portal,loyalty.model_loyalty_rule,base.group_portal,1,0,0,0
access_loyalty_verification_otp_admin,loyalty.verification.otp.admin,model_loyalty_verification_otp,base.group_system,1,1,1,1
access_loyalty_verification_otp_portal,loyalty.verification.otp.portal,model_loyalty_verification_otp,base.group_portal,1,1,1,1
-
diff --git a/views/app_carousel_views.xml b/views/app_carousel_views.xml
new file mode 100644
index 0000000..0a5775c
--- /dev/null
+++ b/views/app_carousel_views.xml
@@ -0,0 +1,78 @@
+
+
+ No carousel slides yet!
+
+ Add slides to display on the app home screen banner carousel.
+ Drag the sequence handle to reorder slides.
+
+ No promo highlights yet!
+
+ Add promo items to display below the carousel on the app home screen.
+ Users can tap a promo to read the full detail.
+
+
+
+
+
+