Update Member
This commit is contained in:
parent
18003313dd
commit
c41826bb1b
1
go.mod
1
go.mod
@ -85,6 +85,7 @@ require (
|
||||
github.com/xuri/excelize/v2 v2.9.0
|
||||
go.uber.org/zap v1.21.0
|
||||
golang.org/x/crypto v0.28.0
|
||||
golang.org/x/exp v0.0.0-20200224162631-6cc2880d07d6
|
||||
golang.org/x/net v0.30.0
|
||||
gorm.io/driver/postgres v1.5.0
|
||||
gorm.io/gorm v1.24.7-0.20230306060331-85eaf9eeda11
|
||||
|
||||
1
go.sum
1
go.sum
@ -343,6 +343,7 @@ golang.org/x/exp v0.0.0-20191129062945-2f5052295587/go.mod h1:2RIsYlXP63K8oxa1u0
|
||||
golang.org/x/exp v0.0.0-20191227195350-da58074b4299/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4=
|
||||
golang.org/x/exp v0.0.0-20200119233911-0405dc783f0a/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4=
|
||||
golang.org/x/exp v0.0.0-20200207192155-f17229e696bd/go.mod h1:J/WKrq2StrnmMY6+EHIKF9dgMWnmCNThgcyBT1FY9mM=
|
||||
golang.org/x/exp v0.0.0-20200224162631-6cc2880d07d6 h1:QE6XYQK6naiK1EPAe1g/ILLxN5RBoH5xkJk3CqlMI/Y=
|
||||
golang.org/x/exp v0.0.0-20200224162631-6cc2880d07d6/go.mod h1:3jZMyOhIsHpP37uCMkUooju7aAi5cS1Q23tOzKc+0MU=
|
||||
golang.org/x/image v0.0.0-20190227222117-0694c2d4d067/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js=
|
||||
golang.org/x/image v0.0.0-20190802002840-cff245a6509b/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
|
||||
|
||||
@ -22,6 +22,7 @@ const (
|
||||
errInactivePartner ErrType = "Partner's license is invalid or has expired. Please contact Admin Support."
|
||||
errTicketAlreadyUsed ErrType = "Ticket Already Used."
|
||||
errProductIsRequired ErrType = "Product"
|
||||
errEmailAndPhoneNumberRequired ErrType = "Email or Phone is required"
|
||||
)
|
||||
|
||||
var (
|
||||
@ -41,6 +42,7 @@ var (
|
||||
ErrorInsufficientBalance = NewServiceException(errInsufficientBalance)
|
||||
ErrorInvalidLicense = NewServiceException(errInactivePartner)
|
||||
ErrorTicketInvalidOrAlreadyUsed = NewServiceException(errTicketAlreadyUsed)
|
||||
ErrorPhoneNumberEmailIsRequired = NewServiceException(errEmailAndPhoneNumberRequired)
|
||||
)
|
||||
|
||||
type Error interface {
|
||||
|
||||
@ -52,3 +52,15 @@ func GenerateRefID() string {
|
||||
var TimeNow = func() time.Time {
|
||||
return time.Now()
|
||||
}
|
||||
|
||||
type RegistrationStatus string
|
||||
|
||||
const (
|
||||
RegistrationSuccess RegistrationStatus = "SUCCESS"
|
||||
RegistrationPending RegistrationStatus = "PENDING"
|
||||
RegistrationFailed RegistrationStatus = "FAILED"
|
||||
)
|
||||
|
||||
func (u RegistrationStatus) String() string {
|
||||
return string(u)
|
||||
}
|
||||
|
||||
@ -189,6 +189,7 @@ func (o *UserDB) SetDeleted(updatedby int64) {
|
||||
o.Status = userstatus.Inactive
|
||||
}
|
||||
|
||||
type MemberList []*Customer
|
||||
type CustomerList []*UserDB
|
||||
|
||||
type CustomerSearch struct {
|
||||
@ -210,3 +211,9 @@ func (b *CustomerList) ToCustomerList() []*Customer {
|
||||
}
|
||||
return users
|
||||
}
|
||||
|
||||
type MemberSearch struct {
|
||||
Search string
|
||||
Limit int
|
||||
Offset int
|
||||
}
|
||||
|
||||
@ -1,8 +1,17 @@
|
||||
package entity
|
||||
|
||||
import "time"
|
||||
|
||||
type CustomerResolutionRequest struct {
|
||||
ID *int64
|
||||
Name string
|
||||
Email string
|
||||
PhoneNumber string
|
||||
BirthDate time.Time
|
||||
}
|
||||
|
||||
type CustomerCheckResponse struct {
|
||||
Exists bool
|
||||
Customer *Customer
|
||||
Message string
|
||||
}
|
||||
|
||||
70
internal/entity/member.go
Normal file
70
internal/entity/member.go
Normal file
@ -0,0 +1,70 @@
|
||||
package entity
|
||||
|
||||
import (
|
||||
"enaklo-pos-be/internal/constants"
|
||||
"time"
|
||||
)
|
||||
|
||||
type MemberRegistrationRequest struct {
|
||||
Name string `json:"name" validate:"required"`
|
||||
Email string `json:"email" validate:"required,email"`
|
||||
Phone string `json:"phone" validate:"required"`
|
||||
BirthDate time.Time `json:"birth_date"`
|
||||
BranchID int64 `json:"branch_id" validate:"required"`
|
||||
CashierID int64 `json:"cashier_id" validate:"required"`
|
||||
}
|
||||
|
||||
type MemberRegistrationResponse struct {
|
||||
Token string `json:"token"`
|
||||
Status string `json:"status"`
|
||||
ExpiresAt time.Time `json:"expires_at"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
type MemberRegistration struct {
|
||||
ID string `json:"id"`
|
||||
Token string `json:"token"`
|
||||
Name string `json:"name"`
|
||||
Email string `json:"email"`
|
||||
Phone string `json:"phone"`
|
||||
BirthDate time.Time `json:"birth_date"`
|
||||
OTP string `json:"-"` // Not exposed in JSON responses
|
||||
Status constants.RegistrationStatus `json:"status"`
|
||||
ExpiresAt time.Time `json:"expires_at"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at,omitempty"`
|
||||
BranchID int64 `json:"branch_id"`
|
||||
CashierID int64 `json:"cashier_id"`
|
||||
}
|
||||
|
||||
type MemberVerificationRequest struct {
|
||||
Token string `json:"token" validate:"required"`
|
||||
OTP string `json:"otp" validate:"required"`
|
||||
}
|
||||
|
||||
type MemberVerificationResponse struct {
|
||||
CustomerID int64 `json:"customer_id"`
|
||||
Name string `json:"name"`
|
||||
Email string `json:"email"`
|
||||
Phone string `json:"phone"`
|
||||
Points int `json:"points"`
|
||||
Status string `json:"status"`
|
||||
}
|
||||
|
||||
type MemberRegistrationStatus struct {
|
||||
Token string `json:"token"`
|
||||
Status string `json:"status"`
|
||||
ExpiresAt time.Time `json:"expires_at"`
|
||||
IsExpired bool `json:"is_expired"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
type ResendOTPRequest struct {
|
||||
Token string `json:"token" validate:"required"`
|
||||
}
|
||||
|
||||
type ResendOTPResponse struct {
|
||||
Token string `json:"token"`
|
||||
ExpiresAt time.Time `json:"expires_at"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
@ -75,7 +75,7 @@ type OrderItem struct {
|
||||
ItemID int64 `gorm:"type:int;column:item_id"`
|
||||
ItemType string `gorm:"type:varchar;column:item_type"`
|
||||
Price float64 `gorm:"type:numeric;not null;column:price"`
|
||||
Quantity int `gorm:"type:int;column:qty"`
|
||||
Quantity int `gorm:"type:int;column:quantity"`
|
||||
CreatedAt time.Time `gorm:"autoCreateTime;column:created_at"`
|
||||
UpdatedAt time.Time `gorm:"autoUpdateTime;column:updated_at"`
|
||||
CreatedBy int64 `gorm:"type:int;column:created_by"`
|
||||
|
||||
@ -49,6 +49,8 @@ type Customer struct {
|
||||
SiteName string
|
||||
PartnerName string
|
||||
ResetPassword bool
|
||||
CustomerID string
|
||||
BirthDate time.Time
|
||||
}
|
||||
|
||||
type AuthenticateUser struct {
|
||||
|
||||
81
internal/handlers/http/customer.go
Normal file
81
internal/handlers/http/customer.go
Normal file
@ -0,0 +1,81 @@
|
||||
package http
|
||||
|
||||
import (
|
||||
"enaklo-pos-be/internal/services/v2/customer"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"enaklo-pos-be/internal/common/errors"
|
||||
"enaklo-pos-be/internal/entity"
|
||||
"enaklo-pos-be/internal/handlers/request"
|
||||
"enaklo-pos-be/internal/handlers/response"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/go-playground/validator/v10"
|
||||
)
|
||||
|
||||
type CustomerHandler struct {
|
||||
service customer.Service
|
||||
}
|
||||
|
||||
func NewCustomerHandler(service customer.Service) *CustomerHandler {
|
||||
return &CustomerHandler{
|
||||
service: service,
|
||||
}
|
||||
}
|
||||
|
||||
func (h *CustomerHandler) Route(group *gin.RouterGroup, jwt gin.HandlerFunc) {
|
||||
route := group.Group("/customers")
|
||||
|
||||
route.GET("/list", jwt, h.GetCustomerList)
|
||||
}
|
||||
|
||||
func (h *CustomerHandler) GetCustomerList(c *gin.Context) {
|
||||
ctx := request.GetMyContext(c)
|
||||
|
||||
searchQuery := c.DefaultQuery("search", "")
|
||||
limitStr := c.DefaultQuery("limit", "10")
|
||||
offsetStr := c.DefaultQuery("offset", "0")
|
||||
|
||||
// Convert limit and offset to integers
|
||||
limit, err := strconv.Atoi(limitStr)
|
||||
if err != nil {
|
||||
response.ErrorWrapper(c, errors.ErrorBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
offset, err := strconv.Atoi(offsetStr)
|
||||
if err != nil {
|
||||
response.ErrorWrapper(c, errors.ErrorBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
req := &entity.MemberSearch{
|
||||
Search: searchQuery,
|
||||
Limit: limit,
|
||||
Offset: offset,
|
||||
}
|
||||
|
||||
validate := validator.New()
|
||||
if err := validate.Struct(req); err != nil {
|
||||
response.ErrorWrapper(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
customerList, totalCount, err := h.service.GetAllCustomers(ctx, req)
|
||||
if err != nil {
|
||||
response.ErrorWrapper(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, response.BaseResponse{
|
||||
Success: true,
|
||||
Status: http.StatusOK,
|
||||
Data: response.MapToCustomerListResponse(customerList),
|
||||
PagingMeta: &response.PagingMeta{
|
||||
Page: offset + 1,
|
||||
Total: int64(totalCount),
|
||||
Limit: limit,
|
||||
},
|
||||
})
|
||||
}
|
||||
154
internal/handlers/http/member.go
Normal file
154
internal/handlers/http/member.go
Normal file
@ -0,0 +1,154 @@
|
||||
package http
|
||||
|
||||
import (
|
||||
"enaklo-pos-be/internal/common/errors"
|
||||
"enaklo-pos-be/internal/entity"
|
||||
"enaklo-pos-be/internal/handlers/request"
|
||||
"enaklo-pos-be/internal/handlers/response"
|
||||
"enaklo-pos-be/internal/services/member"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/go-playground/validator/v10"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
type MemberHandler struct {
|
||||
service member.RegistrationService
|
||||
}
|
||||
|
||||
func NewMemberRegistrationHandler(service member.RegistrationService) *MemberHandler {
|
||||
return &MemberHandler{
|
||||
service: service,
|
||||
}
|
||||
}
|
||||
|
||||
func (h *MemberHandler) Route(group *gin.RouterGroup, jwt gin.HandlerFunc) {
|
||||
route := group.Group("/member")
|
||||
|
||||
route.POST("/register", jwt, h.InitiateRegistration)
|
||||
route.POST("/verify", jwt, h.VerifyOTP)
|
||||
route.GET("/status", jwt, h.GetRegistrationStatus)
|
||||
route.POST("/resend-otp", jwt, h.ResendOTP)
|
||||
route.GET("/list", jwt, h.GetRegistrationStatus)
|
||||
}
|
||||
|
||||
func (h *MemberHandler) InitiateRegistration(c *gin.Context) {
|
||||
ctx := request.GetMyContext(c)
|
||||
userID := ctx.RequestedBy()
|
||||
|
||||
var req request.InitiateRegistrationRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.ErrorWrapper(c, errors.ErrorBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
validate := validator.New()
|
||||
if err := validate.Struct(req); err != nil {
|
||||
response.ErrorWrapper(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
birthDate, err := req.GetBirthdate()
|
||||
if err != nil {
|
||||
response.ErrorWrapper(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
memberReq := &entity.MemberRegistrationRequest{
|
||||
Name: req.Name,
|
||||
Email: req.Email,
|
||||
Phone: req.Phone,
|
||||
BirthDate: birthDate,
|
||||
BranchID: *ctx.GetPartnerID(),
|
||||
CashierID: userID,
|
||||
}
|
||||
|
||||
result, err := h.service.InitiateRegistration(ctx, memberReq)
|
||||
if err != nil {
|
||||
response.ErrorWrapper(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, response.BaseResponse{
|
||||
Success: true,
|
||||
Status: http.StatusOK,
|
||||
Data: response.MapToMemberRegistrationResponse(result),
|
||||
})
|
||||
}
|
||||
|
||||
func (h *MemberHandler) VerifyOTP(c *gin.Context) {
|
||||
ctx := request.GetMyContext(c)
|
||||
|
||||
var req request.VerifyOTPRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.ErrorWrapper(c, errors.ErrorBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
validate := validator.New()
|
||||
if err := validate.Struct(req); err != nil {
|
||||
response.ErrorWrapper(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
result, err := h.service.VerifyOTP(ctx, req.Token, req.OTP)
|
||||
if err != nil {
|
||||
response.ErrorWrapper(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, response.BaseResponse{
|
||||
Success: true,
|
||||
Status: http.StatusOK,
|
||||
Data: response.MapToMemberVerificationResponse(result),
|
||||
})
|
||||
}
|
||||
|
||||
func (h *MemberHandler) GetRegistrationStatus(c *gin.Context) {
|
||||
ctx := request.GetMyContext(c)
|
||||
token := c.Query("token")
|
||||
|
||||
if token == "" {
|
||||
response.ErrorWrapper(c, errors.ErrorBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
result, err := h.service.GetRegistrationStatus(ctx, token)
|
||||
if err != nil {
|
||||
response.ErrorWrapper(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, response.BaseResponse{
|
||||
Success: true,
|
||||
Status: http.StatusOK,
|
||||
Data: response.MapToMemberRegistrationStatus(result),
|
||||
})
|
||||
}
|
||||
|
||||
func (h *MemberHandler) ResendOTP(c *gin.Context) {
|
||||
ctx := request.GetMyContext(c)
|
||||
|
||||
var req entity.ResendOTPRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.ErrorWrapper(c, errors.ErrorBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
validate := validator.New()
|
||||
if err := validate.Struct(req); err != nil {
|
||||
response.ErrorWrapper(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
result, err := h.service.ResendOTP(ctx, req.Token)
|
||||
if err != nil {
|
||||
response.ErrorWrapper(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, response.BaseResponse{
|
||||
Success: true,
|
||||
Status: http.StatusOK,
|
||||
Data: response.MapToResendOTPResponse(result),
|
||||
})
|
||||
}
|
||||
34
internal/handlers/request/member.go
Normal file
34
internal/handlers/request/member.go
Normal file
@ -0,0 +1,34 @@
|
||||
package request
|
||||
|
||||
import (
|
||||
"time"
|
||||
)
|
||||
|
||||
type InitiateRegistrationRequest struct {
|
||||
Name string `json:"name" validate:"required"`
|
||||
Email string `json:"email" validate:"required,email"`
|
||||
Phone string `json:"phone" validate:"required"`
|
||||
BirthDate string `json:"birth_date" validate:"required"`
|
||||
}
|
||||
|
||||
func (i *InitiateRegistrationRequest) GetBirthdate() (time.Time, error) {
|
||||
parsedDate, err := time.Parse("02-01-2006", i.BirthDate)
|
||||
if err != nil {
|
||||
return time.Time{}, err
|
||||
}
|
||||
return parsedDate, nil
|
||||
}
|
||||
|
||||
type VerifyOTPRequest struct {
|
||||
Token string `json:"token" validate:"required"`
|
||||
OTP string `json:"otp" validate:"required"`
|
||||
}
|
||||
|
||||
type ResendOTPRequest struct {
|
||||
Token string `json:"token" validate:"required"`
|
||||
}
|
||||
|
||||
type CheckCustomerRequest struct {
|
||||
Email string `json:"email"`
|
||||
Phone string `json:"phone"`
|
||||
}
|
||||
35
internal/handlers/response/customer.go
Normal file
35
internal/handlers/response/customer.go
Normal file
@ -0,0 +1,35 @@
|
||||
package response
|
||||
|
||||
import (
|
||||
"enaklo-pos-be/internal/entity"
|
||||
)
|
||||
|
||||
func MapToCustomerResponse(customer *entity.Customer) CustomerResponse {
|
||||
if customer == nil {
|
||||
return CustomerResponse{}
|
||||
}
|
||||
|
||||
return CustomerResponse{
|
||||
ID: customer.ID,
|
||||
Name: customer.Name,
|
||||
Email: customer.Email,
|
||||
Phone: customer.Phone,
|
||||
Points: customer.Points,
|
||||
CustomerID: customer.CustomerID,
|
||||
CreatedAt: customer.CreatedAt.Format("2006-01-02"),
|
||||
BirthDate: customer.BirthDate.Format("2006-01-02"),
|
||||
}
|
||||
}
|
||||
|
||||
func MapToCustomerListResponse(customers *entity.MemberList) []CustomerResponse {
|
||||
if customers == nil {
|
||||
return []CustomerResponse{}
|
||||
}
|
||||
|
||||
responseList := []CustomerResponse{}
|
||||
for _, customer := range *customers {
|
||||
responseList = append(responseList, MapToCustomerResponse(customer))
|
||||
}
|
||||
|
||||
return responseList
|
||||
}
|
||||
111
internal/handlers/response/member.go
Normal file
111
internal/handlers/response/member.go
Normal file
@ -0,0 +1,111 @@
|
||||
package response
|
||||
|
||||
import (
|
||||
"enaklo-pos-be/internal/entity"
|
||||
"time"
|
||||
)
|
||||
|
||||
type MemberRegistrationResponse struct {
|
||||
Token string `json:"token"`
|
||||
Status string `json:"status"`
|
||||
ExpiresAt time.Time `json:"expires_at"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
type MemberVerificationResponse struct {
|
||||
CustomerID int64 `json:"customer_id"`
|
||||
Name string `json:"name"`
|
||||
Email string `json:"email"`
|
||||
Phone string `json:"phone"`
|
||||
Points int `json:"points"`
|
||||
Status string `json:"status"`
|
||||
}
|
||||
|
||||
type MemberRegistrationStatus struct {
|
||||
Token string `json:"token"`
|
||||
Status string `json:"status"`
|
||||
ExpiresAt time.Time `json:"expires_at"`
|
||||
IsExpired bool `json:"is_expired"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
type ResendOTPResponse struct {
|
||||
Token string `json:"token"`
|
||||
ExpiresAt time.Time `json:"expires_at"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
type CustomerCheckResponse struct {
|
||||
Exists bool `json:"exists"`
|
||||
Customer *CustomerResponse `json:"customer,omitempty"`
|
||||
Message string `json:"message,omitempty"`
|
||||
}
|
||||
|
||||
type CustomerResponse struct {
|
||||
ID int64 `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Email string `json:"email"`
|
||||
Phone string `json:"phone"`
|
||||
BirthDate string `json:"birth_date,omitempty"`
|
||||
Points int `json:"points"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
CustomerID string `json:"customer_id"`
|
||||
}
|
||||
|
||||
func MapToMemberRegistrationResponse(entity *entity.MemberRegistrationResponse) MemberRegistrationResponse {
|
||||
return MemberRegistrationResponse{
|
||||
Token: entity.Token,
|
||||
Status: entity.Status,
|
||||
ExpiresAt: entity.ExpiresAt,
|
||||
Message: entity.Message,
|
||||
}
|
||||
}
|
||||
|
||||
func MapToMemberVerificationResponse(entity *entity.MemberVerificationResponse) MemberVerificationResponse {
|
||||
return MemberVerificationResponse{
|
||||
CustomerID: entity.CustomerID,
|
||||
Name: entity.Name,
|
||||
Email: entity.Email,
|
||||
Phone: entity.Phone,
|
||||
Points: entity.Points,
|
||||
Status: entity.Status,
|
||||
}
|
||||
}
|
||||
|
||||
func MapToMemberRegistrationStatus(entity *entity.MemberRegistrationStatus) MemberRegistrationStatus {
|
||||
return MemberRegistrationStatus{
|
||||
Token: entity.Token,
|
||||
Status: entity.Status,
|
||||
ExpiresAt: entity.ExpiresAt,
|
||||
IsExpired: entity.IsExpired,
|
||||
CreatedAt: entity.CreatedAt,
|
||||
}
|
||||
}
|
||||
|
||||
func MapToResendOTPResponse(entity *entity.ResendOTPResponse) ResendOTPResponse {
|
||||
return ResendOTPResponse{
|
||||
Token: entity.Token,
|
||||
ExpiresAt: entity.ExpiresAt,
|
||||
Message: entity.Message,
|
||||
}
|
||||
}
|
||||
|
||||
func MapToCustomerCheckResponse(entity *entity.CustomerCheckResponse) CustomerCheckResponse {
|
||||
response := CustomerCheckResponse{
|
||||
Exists: entity.Exists,
|
||||
Message: entity.Message,
|
||||
}
|
||||
|
||||
if entity.Customer != nil {
|
||||
customer := &CustomerResponse{
|
||||
ID: entity.Customer.ID,
|
||||
Name: entity.Customer.Name,
|
||||
Email: entity.Customer.Email,
|
||||
Phone: entity.Customer.Phone,
|
||||
CreatedAt: entity.Customer.CreatedAt.Format("2006-01-02"),
|
||||
}
|
||||
response.Customer = customer
|
||||
}
|
||||
|
||||
return response
|
||||
}
|
||||
@ -15,6 +15,8 @@ type CustomerRepo interface {
|
||||
FindByPhone(ctx mycontext.Context, phone string) (*entity.Customer, error)
|
||||
FindByEmail(ctx mycontext.Context, email string) (*entity.Customer, error)
|
||||
AddPoints(ctx mycontext.Context, id int64, points int) error
|
||||
FindSequence(ctx mycontext.Context, partnerID int64) (int64, error)
|
||||
GetAllCustomers(ctx mycontext.Context, req entity.MemberSearch) (entity.MemberList, int, error)
|
||||
}
|
||||
|
||||
type customerRepository struct {
|
||||
@ -112,9 +114,69 @@ func (r *customerRepository) toCustomerDBModel(customer *entity.Customer) models
|
||||
Points: customer.Points,
|
||||
CreatedAt: customer.CreatedAt,
|
||||
UpdatedAt: customer.UpdatedAt,
|
||||
CustomerID: customer.CustomerID,
|
||||
BirthDate: customer.BirthDate,
|
||||
}
|
||||
}
|
||||
|
||||
func (r *customerRepository) FindSequence(ctx mycontext.Context, partnerID int64) (int64, error) {
|
||||
tx := r.db.Begin()
|
||||
if tx.Error != nil {
|
||||
return 0, errors.Wrap(tx.Error, "failed to begin transaction")
|
||||
}
|
||||
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
tx.Rollback()
|
||||
}
|
||||
}()
|
||||
|
||||
var sequence models.PartnerMemberSequence
|
||||
|
||||
result := tx.Where("partner_id = ?", partnerID).First(&sequence)
|
||||
if result.Error != nil {
|
||||
if errors.Is(result.Error, gorm.ErrRecordNotFound) {
|
||||
now := time.Now()
|
||||
newSequence := models.PartnerMemberSequence{
|
||||
PartnerID: partnerID,
|
||||
LastSequence: 1,
|
||||
UpdatedAt: now,
|
||||
}
|
||||
|
||||
if err := tx.Create(&newSequence).Error; err != nil {
|
||||
tx.Rollback()
|
||||
return 0, errors.Wrap(err, "failed to create new sequence")
|
||||
}
|
||||
|
||||
if err := tx.Commit().Error; err != nil {
|
||||
return 0, errors.Wrap(err, "failed to commit transaction")
|
||||
}
|
||||
|
||||
return 1, nil
|
||||
}
|
||||
|
||||
tx.Rollback()
|
||||
return 0, errors.Wrap(result.Error, "failed to query sequence")
|
||||
}
|
||||
|
||||
newSequenceValue := sequence.LastSequence + 1
|
||||
updates := map[string]interface{}{
|
||||
"last_sequence": newSequenceValue,
|
||||
"updated_at": time.Now(),
|
||||
}
|
||||
|
||||
if err := tx.Model(&sequence).Updates(updates).Error; err != nil {
|
||||
tx.Rollback()
|
||||
return 0, errors.Wrap(err, "failed to update sequence")
|
||||
}
|
||||
|
||||
if err := tx.Commit().Error; err != nil {
|
||||
return 0, errors.Wrap(err, "failed to commit transaction")
|
||||
}
|
||||
|
||||
return newSequenceValue, nil
|
||||
}
|
||||
|
||||
func (r *customerRepository) toDomainCustomerModel(dbModel *models.CustomerDB) *entity.Customer {
|
||||
return &entity.Customer{
|
||||
ID: dbModel.ID,
|
||||
@ -124,5 +186,49 @@ func (r *customerRepository) toDomainCustomerModel(dbModel *models.CustomerDB) *
|
||||
Points: dbModel.Points,
|
||||
CreatedAt: dbModel.CreatedAt,
|
||||
UpdatedAt: dbModel.UpdatedAt,
|
||||
CustomerID: dbModel.CustomerID,
|
||||
BirthDate: dbModel.BirthDate,
|
||||
}
|
||||
}
|
||||
|
||||
func (r *customerRepository) GetAllCustomers(ctx mycontext.Context, req entity.MemberSearch) (entity.MemberList, int, error) {
|
||||
if req.Limit <= 0 {
|
||||
req.Limit = 10
|
||||
}
|
||||
if req.Offset < 0 {
|
||||
req.Offset = 0
|
||||
}
|
||||
|
||||
query := r.db.Model(&models.CustomerDB{})
|
||||
|
||||
if req.Search != "" {
|
||||
searchTerm := "%" + req.Search + "%"
|
||||
query = query.Where(
|
||||
"name ILIKE ? OR email ILIKE ? OR phone ILIKE ?",
|
||||
searchTerm, searchTerm, searchTerm,
|
||||
)
|
||||
}
|
||||
|
||||
var totalCount int64
|
||||
if err := query.Count(&totalCount).Error; err != nil {
|
||||
return nil, 0, errors.Wrap(err, "failed to count customers")
|
||||
}
|
||||
|
||||
var customersDB []models.CustomerDB
|
||||
result := query.
|
||||
Order("created_at DESC").
|
||||
Limit(req.Limit).
|
||||
Offset(req.Offset).
|
||||
Find(&customersDB)
|
||||
|
||||
if result.Error != nil {
|
||||
return nil, 0, errors.Wrap(result.Error, "failed to retrieve customers")
|
||||
}
|
||||
|
||||
customers := make(entity.MemberList, len(customersDB))
|
||||
for i, customerDB := range customersDB {
|
||||
customers[i] = r.toDomainCustomerModel(&customerDB)
|
||||
}
|
||||
|
||||
return customers, int(totalCount), nil
|
||||
}
|
||||
|
||||
138
internal/repository/member_repo.go
Normal file
138
internal/repository/member_repo.go
Normal file
@ -0,0 +1,138 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"enaklo-pos-be/internal/common/logger"
|
||||
"enaklo-pos-be/internal/common/mycontext"
|
||||
"enaklo-pos-be/internal/constants"
|
||||
"enaklo-pos-be/internal/entity"
|
||||
"enaklo-pos-be/internal/repository/models"
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"go.uber.org/zap"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type MemberRepository interface {
|
||||
CreateRegistration(ctx mycontext.Context, registration *entity.MemberRegistration) (*entity.MemberRegistration, error)
|
||||
GetRegistrationByToken(ctx mycontext.Context, token string) (*entity.MemberRegistration, error)
|
||||
UpdateRegistrationStatus(ctx mycontext.Context, token string, status constants.RegistrationStatus) error
|
||||
UpdateRegistrationOTP(ctx mycontext.Context, token string, otp string, expiresAt time.Time) error
|
||||
}
|
||||
|
||||
type memberRepository struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
func NewMemberRepository(db *gorm.DB) MemberRepository {
|
||||
return &memberRepository{
|
||||
db: db,
|
||||
}
|
||||
}
|
||||
|
||||
func (r *memberRepository) CreateRegistration(ctx mycontext.Context, registration *entity.MemberRegistration) (*entity.MemberRegistration, error) {
|
||||
registrationDB := r.toRegistrationDBModel(registration)
|
||||
|
||||
if err := r.db.Create(®istrationDB).Error; err != nil {
|
||||
logger.ContextLogger(ctx).Error("failed to create member registration", zap.Error(err))
|
||||
return nil, errors.New("failed to insert member registration")
|
||||
}
|
||||
|
||||
return registration, nil
|
||||
}
|
||||
|
||||
func (r *memberRepository) GetRegistrationByToken(ctx mycontext.Context, token string) (*entity.MemberRegistration, error) {
|
||||
var registrationDB models.MemberRegistrationDB
|
||||
|
||||
if err := r.db.Where("token = ?", token).First(®istrationDB).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, errors.New("registration not found")
|
||||
}
|
||||
logger.ContextLogger(ctx).Error("failed to get registration by token", zap.Error(err))
|
||||
return nil, errors.New("failed to get registration by token")
|
||||
}
|
||||
|
||||
registration := r.toDomainRegistrationModel(®istrationDB)
|
||||
return registration, nil
|
||||
}
|
||||
|
||||
func (r *memberRepository) UpdateRegistrationStatus(ctx mycontext.Context, token string, status constants.RegistrationStatus) error {
|
||||
now := time.Now()
|
||||
|
||||
result := r.db.Model(&models.MemberRegistrationDB{}).
|
||||
Where("token = ?", token).
|
||||
Updates(map[string]interface{}{
|
||||
"status": status,
|
||||
"updated_at": now,
|
||||
})
|
||||
|
||||
if result.Error != nil {
|
||||
logger.ContextLogger(ctx).Error("failed to update registration status", zap.Error(result.Error))
|
||||
return errors.New("failed to update registration status")
|
||||
}
|
||||
|
||||
if result.RowsAffected == 0 {
|
||||
return errors.New("registration not found")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *memberRepository) UpdateRegistrationOTP(ctx mycontext.Context, token string, otp string, expiresAt time.Time) error {
|
||||
now := time.Now()
|
||||
|
||||
result := r.db.Model(&models.MemberRegistrationDB{}).
|
||||
Where("token = ?", token).
|
||||
Updates(map[string]interface{}{
|
||||
"otp": otp,
|
||||
"expires_at": expiresAt,
|
||||
"updated_at": now,
|
||||
})
|
||||
|
||||
if result.Error != nil {
|
||||
logger.ContextLogger(ctx).Error("failed to update registration OTP", zap.Error(result.Error))
|
||||
return errors.New("failed to update registration OTP")
|
||||
}
|
||||
|
||||
if result.RowsAffected == 0 {
|
||||
return errors.New("registration not found")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *memberRepository) toRegistrationDBModel(registration *entity.MemberRegistration) models.MemberRegistrationDB {
|
||||
return models.MemberRegistrationDB{
|
||||
ID: registration.ID,
|
||||
Token: registration.Token,
|
||||
Name: registration.Name,
|
||||
Email: registration.Email,
|
||||
Phone: registration.Phone,
|
||||
BirthDate: registration.BirthDate,
|
||||
OTP: registration.OTP,
|
||||
Status: registration.Status.String(),
|
||||
ExpiresAt: registration.ExpiresAt,
|
||||
CreatedAt: registration.CreatedAt,
|
||||
UpdatedAt: registration.UpdatedAt,
|
||||
BranchID: registration.BranchID,
|
||||
CashierID: registration.CashierID,
|
||||
}
|
||||
}
|
||||
|
||||
func (r *memberRepository) toDomainRegistrationModel(dbModel *models.MemberRegistrationDB) *entity.MemberRegistration {
|
||||
return &entity.MemberRegistration{
|
||||
ID: dbModel.ID,
|
||||
Token: dbModel.Token,
|
||||
Name: dbModel.Name,
|
||||
Email: dbModel.Email,
|
||||
Phone: dbModel.Phone,
|
||||
BirthDate: dbModel.BirthDate,
|
||||
OTP: dbModel.OTP,
|
||||
Status: constants.RegistrationStatus(dbModel.Status),
|
||||
ExpiresAt: dbModel.ExpiresAt,
|
||||
CreatedAt: dbModel.CreatedAt,
|
||||
UpdatedAt: dbModel.UpdatedAt,
|
||||
BranchID: dbModel.BranchID,
|
||||
CashierID: dbModel.CashierID,
|
||||
}
|
||||
}
|
||||
@ -12,8 +12,21 @@ type CustomerDB struct {
|
||||
Points int `gorm:"column:points"`
|
||||
CreatedAt time.Time `gorm:"column:created_at"`
|
||||
UpdatedAt time.Time `gorm:"column:updated_at"`
|
||||
CustomerID string `gorm:"column:customer_id"`
|
||||
BirthDate time.Time `gorm:"column:birth_date"`
|
||||
}
|
||||
|
||||
func (CustomerDB) TableName() string {
|
||||
return "customers"
|
||||
}
|
||||
|
||||
type PartnerMemberSequence struct {
|
||||
ID int64 `gorm:"column:id;primary_key;auto_increment"`
|
||||
PartnerID int64 `gorm:"column:partner_id;not null;index:idx_partner_month,unique"`
|
||||
LastSequence int64 `gorm:"column:last_sequence;not null;default:0"`
|
||||
UpdatedAt time.Time `gorm:"column:updated_at;not null"`
|
||||
}
|
||||
|
||||
func (PartnerMemberSequence) TableName() string {
|
||||
return "partner_member_sequences"
|
||||
}
|
||||
|
||||
25
internal/repository/models/member.go
Normal file
25
internal/repository/models/member.go
Normal file
@ -0,0 +1,25 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"time"
|
||||
)
|
||||
|
||||
type MemberRegistrationDB struct {
|
||||
ID string `gorm:"column:id;primary_key"`
|
||||
Token string `gorm:"column:token;unique_index"`
|
||||
Name string `gorm:"column:name"`
|
||||
Email string `gorm:"column:email"`
|
||||
Phone string `gorm:"column:phone"`
|
||||
BirthDate time.Time `gorm:"column:birth_date"`
|
||||
OTP string `gorm:"column:otp"`
|
||||
Status string `gorm:"column:status"`
|
||||
ExpiresAt time.Time `gorm:"column:expires_at"`
|
||||
CreatedAt time.Time `gorm:"column:created_at"`
|
||||
UpdatedAt time.Time `gorm:"column:updated_at"`
|
||||
BranchID int64 `gorm:"column:branch_id"`
|
||||
CashierID int64 `gorm:"column:cashier_id"`
|
||||
}
|
||||
|
||||
func (MemberRegistrationDB) TableName() string {
|
||||
return "member_registrations"
|
||||
}
|
||||
@ -56,6 +56,7 @@ type RepoManagerImpl struct {
|
||||
CustomerRepo CustomerRepo
|
||||
ProductRepo ProductRepository
|
||||
TransactionRepo TransactionRepo
|
||||
MemberRepository MemberRepository
|
||||
}
|
||||
|
||||
func NewRepoManagerImpl(db *gorm.DB, cfg *config.Config) *RepoManagerImpl {
|
||||
@ -84,6 +85,7 @@ func NewRepoManagerImpl(db *gorm.DB, cfg *config.Config) *RepoManagerImpl {
|
||||
CustomerRepo: NewCustomerRepository(db),
|
||||
ProductRepo: NewproductRepository(db),
|
||||
TransactionRepo: NewTransactionRepository(db),
|
||||
MemberRepository: NewMemberRepository(db),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -78,6 +78,8 @@ func RegisterPrivateRoutesV2(app *app.Server, serviceManager *services.ServiceMa
|
||||
|
||||
serverRoutes := []HTTPHandlerRoutes{
|
||||
http2.NewOrderHandler(serviceManager.OrderV2Svc),
|
||||
http2.NewMemberRegistrationHandler(serviceManager.MemberRegistrationSvc),
|
||||
http2.NewCustomerHandler(serviceManager.CustomerV2Svc),
|
||||
}
|
||||
|
||||
for _, handler := range serverRoutes {
|
||||
|
||||
51
internal/services/member/member.go
Normal file
51
internal/services/member/member.go
Normal file
@ -0,0 +1,51 @@
|
||||
package member
|
||||
|
||||
import (
|
||||
"context"
|
||||
"enaklo-pos-be/internal/common/mycontext"
|
||||
"enaklo-pos-be/internal/constants"
|
||||
"enaklo-pos-be/internal/entity"
|
||||
"time"
|
||||
)
|
||||
|
||||
type RegistrationService interface {
|
||||
InitiateRegistration(ctx mycontext.Context, request *entity.MemberRegistrationRequest) (*entity.MemberRegistrationResponse, error)
|
||||
VerifyOTP(ctx mycontext.Context, token string, otp string) (*entity.MemberVerificationResponse, error)
|
||||
GetRegistrationStatus(ctx mycontext.Context, token string) (*entity.MemberRegistrationStatus, error)
|
||||
ResendOTP(ctx mycontext.Context, token string) (*entity.ResendOTPResponse, error)
|
||||
}
|
||||
|
||||
type memberSvc struct {
|
||||
repo Repository
|
||||
notification NotificationService
|
||||
customerSvc CustomerService
|
||||
}
|
||||
|
||||
type Repository interface {
|
||||
CreateRegistration(ctx mycontext.Context, registration *entity.MemberRegistration) (*entity.MemberRegistration, error)
|
||||
GetRegistrationByToken(ctx mycontext.Context, token string) (*entity.MemberRegistration, error)
|
||||
UpdateRegistrationStatus(ctx mycontext.Context, token string, status constants.RegistrationStatus) error
|
||||
UpdateRegistrationOTP(ctx mycontext.Context, token string, otp string, expiresAt time.Time) error
|
||||
}
|
||||
|
||||
type NotificationService interface {
|
||||
SendEmailTransactional(ctx context.Context, param entity.SendEmailNotificationParam) error
|
||||
}
|
||||
|
||||
type CustomerService interface {
|
||||
ResolveCustomer(ctx mycontext.Context, req *entity.CustomerResolutionRequest) (int64, error)
|
||||
GetCustomer(ctx mycontext.Context, id int64) (*entity.Customer, error)
|
||||
CustomerCheck(ctx mycontext.Context, req *entity.CustomerResolutionRequest) (*entity.CustomerCheckResponse, error)
|
||||
}
|
||||
|
||||
func NewMemberRegistrationService(
|
||||
repo Repository,
|
||||
notification NotificationService,
|
||||
customerSvc CustomerService,
|
||||
) RegistrationService {
|
||||
return &memberSvc{
|
||||
repo: repo,
|
||||
notification: notification,
|
||||
customerSvc: customerSvc,
|
||||
}
|
||||
}
|
||||
262
internal/services/member/member_registration.go
Normal file
262
internal/services/member/member_registration.go
Normal file
@ -0,0 +1,262 @@
|
||||
package member
|
||||
|
||||
import (
|
||||
"enaklo-pos-be/internal/common/logger"
|
||||
"enaklo-pos-be/internal/common/mycontext"
|
||||
"enaklo-pos-be/internal/constants"
|
||||
"enaklo-pos-be/internal/entity"
|
||||
"errors"
|
||||
"go.uber.org/zap"
|
||||
"golang.org/x/exp/rand"
|
||||
"time"
|
||||
)
|
||||
|
||||
func (s *memberSvc) InitiateRegistration(
|
||||
ctx mycontext.Context,
|
||||
request *entity.MemberRegistrationRequest,
|
||||
) (*entity.MemberRegistrationResponse, error) {
|
||||
customerResolution := &entity.CustomerResolutionRequest{
|
||||
Email: request.Email,
|
||||
PhoneNumber: request.Phone,
|
||||
}
|
||||
|
||||
checkResult, err := s.customerSvc.CustomerCheck(ctx, customerResolution)
|
||||
if checkResult.Exists {
|
||||
return nil, errors.New(checkResult.Message)
|
||||
}
|
||||
|
||||
otp := generateOTP(6)
|
||||
|
||||
token := constants.GenerateUUID()
|
||||
|
||||
registration := &entity.MemberRegistration{
|
||||
ID: constants.GenerateUUID(),
|
||||
Token: token,
|
||||
Name: request.Name,
|
||||
Email: request.Email,
|
||||
Phone: request.Phone,
|
||||
BirthDate: request.BirthDate,
|
||||
OTP: otp,
|
||||
Status: constants.RegistrationPending,
|
||||
ExpiresAt: constants.TimeNow().Add(10 * time.Minute), // OTP expires in 10 minutes
|
||||
CreatedAt: constants.TimeNow(),
|
||||
UpdatedAt: constants.TimeNow(),
|
||||
BranchID: request.BranchID,
|
||||
CashierID: request.CashierID,
|
||||
}
|
||||
|
||||
savedRegistration, err := s.repo.CreateRegistration(ctx, registration)
|
||||
if err != nil {
|
||||
logger.ContextLogger(ctx).Error("failed to create member registration", zap.Error(err))
|
||||
return nil, err
|
||||
}
|
||||
|
||||
err = s.sendRegistrationOTP(ctx, savedRegistration)
|
||||
if err != nil {
|
||||
logger.ContextLogger(ctx).Warn("failed to send OTP", zap.Error(err))
|
||||
}
|
||||
|
||||
return &entity.MemberRegistrationResponse{
|
||||
Token: token,
|
||||
Status: savedRegistration.Status.String(),
|
||||
ExpiresAt: savedRegistration.ExpiresAt,
|
||||
Message: "Registration initiated. Please verify with OTP sent to your email.",
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *memberSvc) VerifyOTP(
|
||||
ctx mycontext.Context,
|
||||
token string,
|
||||
otp string,
|
||||
) (*entity.MemberVerificationResponse, error) {
|
||||
logger.ContextLogger(ctx).Info("verifying OTP for member registration", zap.String("token", token))
|
||||
|
||||
registration, err := s.repo.GetRegistrationByToken(ctx, token)
|
||||
if err != nil {
|
||||
logger.ContextLogger(ctx).Error("failed to get registration", zap.Error(err))
|
||||
return nil, errors.New("invalid registration token")
|
||||
}
|
||||
|
||||
if registration.Status == constants.RegistrationSuccess {
|
||||
return nil, errors.New("registration already completed")
|
||||
}
|
||||
|
||||
if registration.ExpiresAt.Before(constants.TimeNow()) {
|
||||
return nil, errors.New("registration expired")
|
||||
}
|
||||
|
||||
if registration.OTP != otp {
|
||||
return nil, errors.New("invalid OTP")
|
||||
}
|
||||
|
||||
customerResolution := &entity.CustomerResolutionRequest{
|
||||
Name: registration.Name,
|
||||
Email: registration.Email,
|
||||
PhoneNumber: registration.Phone,
|
||||
BirthDate: registration.BirthDate,
|
||||
}
|
||||
|
||||
customerID, err := s.customerSvc.ResolveCustomer(ctx, customerResolution)
|
||||
if err != nil {
|
||||
logger.ContextLogger(ctx).Error("failed to create customer", zap.Error(err))
|
||||
return nil, errors.New("failed to create member record")
|
||||
}
|
||||
|
||||
err = s.repo.UpdateRegistrationStatus(ctx, token, constants.RegistrationSuccess)
|
||||
if err != nil {
|
||||
logger.ContextLogger(ctx).Warn("failed to update registration status", zap.Error(err))
|
||||
}
|
||||
|
||||
customer, err := s.customerSvc.GetCustomer(ctx, customerID)
|
||||
if err != nil {
|
||||
logger.ContextLogger(ctx).Warn("failed to get created customer", zap.Error(err))
|
||||
|
||||
return &entity.MemberVerificationResponse{
|
||||
CustomerID: customerID,
|
||||
Name: registration.Name,
|
||||
Email: registration.Email,
|
||||
Phone: registration.Phone,
|
||||
Status: "Registration completed successfully",
|
||||
}, nil
|
||||
}
|
||||
|
||||
err = s.sendWelcomeEmail(ctx, customer)
|
||||
if err != nil {
|
||||
logger.ContextLogger(ctx).Warn("failed to send welcome email", zap.Error(err))
|
||||
}
|
||||
|
||||
return &entity.MemberVerificationResponse{
|
||||
CustomerID: customer.ID,
|
||||
Name: customer.Name,
|
||||
Email: customer.Email,
|
||||
Phone: customer.Phone,
|
||||
Points: customer.Points,
|
||||
Status: "Registration completed successfully",
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *memberSvc) GetRegistrationStatus(
|
||||
ctx mycontext.Context,
|
||||
token string,
|
||||
) (*entity.MemberRegistrationStatus, error) {
|
||||
logger.ContextLogger(ctx).Info("checking registration status", zap.String("token", token))
|
||||
|
||||
registration, err := s.repo.GetRegistrationByToken(ctx, token)
|
||||
if err != nil {
|
||||
logger.ContextLogger(ctx).Error("failed to get registration", zap.Error(err))
|
||||
return nil, errors.New("invalid registration token")
|
||||
}
|
||||
|
||||
return &entity.MemberRegistrationStatus{
|
||||
Token: registration.Token,
|
||||
Status: registration.Status.String(),
|
||||
ExpiresAt: registration.ExpiresAt,
|
||||
IsExpired: registration.ExpiresAt.Before(constants.TimeNow()),
|
||||
CreatedAt: registration.CreatedAt,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *memberSvc) ResendOTP(
|
||||
ctx mycontext.Context,
|
||||
token string,
|
||||
) (*entity.ResendOTPResponse, error) {
|
||||
logger.ContextLogger(ctx).Info("resending OTP", zap.String("token", token))
|
||||
|
||||
// Get registration by token
|
||||
registration, err := s.repo.GetRegistrationByToken(ctx, token)
|
||||
if err != nil {
|
||||
logger.ContextLogger(ctx).Error("failed to get registration", zap.Error(err))
|
||||
return nil, errors.New("invalid registration token")
|
||||
}
|
||||
|
||||
if registration.Status == constants.RegistrationSuccess {
|
||||
return nil, errors.New("registration already completed")
|
||||
}
|
||||
|
||||
newOTP := generateOTP(6)
|
||||
newExpiresAt := constants.TimeNow().Add(10 * time.Minute)
|
||||
|
||||
err = s.repo.UpdateRegistrationOTP(ctx, token, newOTP, newExpiresAt)
|
||||
if err != nil {
|
||||
logger.ContextLogger(ctx).Error("failed to update OTP", zap.Error(err))
|
||||
return nil, errors.New("failed to generate new OTP")
|
||||
}
|
||||
|
||||
registration.OTP = newOTP
|
||||
registration.ExpiresAt = newExpiresAt
|
||||
|
||||
err = s.sendRegistrationOTP(ctx, registration)
|
||||
if err != nil {
|
||||
logger.ContextLogger(ctx).Warn("failed to send OTP", zap.Error(err))
|
||||
}
|
||||
|
||||
return &entity.ResendOTPResponse{
|
||||
Token: token,
|
||||
ExpiresAt: newExpiresAt,
|
||||
Message: "OTP has been resent to your email and phone",
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *memberSvc) sendRegistrationOTP(
|
||||
ctx mycontext.Context,
|
||||
registration *entity.MemberRegistration,
|
||||
) error {
|
||||
emailData := map[string]interface{}{
|
||||
"UserName": registration.Name,
|
||||
"OTPCode": registration.OTP,
|
||||
}
|
||||
|
||||
err := s.notification.SendEmailTransactional(ctx, entity.SendEmailNotificationParam{
|
||||
Sender: "noreply@enaklo.co.id",
|
||||
Recipient: registration.Email,
|
||||
Subject: "Enaklo - Registration Verification Code",
|
||||
TemplateName: "member_registration_otp",
|
||||
TemplatePath: "templates/member_registration_otp.html",
|
||||
Data: emailData,
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
//if registration.Phone != "" {
|
||||
// smsMessage := fmt.Sprintf("Your Enaklo registration code is: %s. Please provide this code to our staff to complete your registration.", registration.OTP)
|
||||
// _ = s.notification.SendSMS(ctx, registration.Phone, smsMessage)
|
||||
//}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *memberSvc) sendWelcomeEmail(
|
||||
ctx mycontext.Context,
|
||||
customer *entity.Customer,
|
||||
) error {
|
||||
|
||||
welcomeData := map[string]interface{}{
|
||||
"UserName": customer.Name,
|
||||
"MemberID": customer.CustomerID,
|
||||
"PointsName": "PoinLo",
|
||||
"PointsBalance": customer.Points,
|
||||
"RedeemLink": "https://enaklo.co.id/redeem",
|
||||
"CurrentDate": time.Now().Format("01-20006"),
|
||||
}
|
||||
|
||||
return s.notification.SendEmailTransactional(ctx, entity.SendEmailNotificationParam{
|
||||
Sender: "noreply@enaklo.co.id",
|
||||
Recipient: customer.Email,
|
||||
Subject: "Welcome to Enaklo Membership Program",
|
||||
TemplateName: "welcome_member",
|
||||
TemplatePath: "templates/welcome_member.html",
|
||||
Data: welcomeData,
|
||||
})
|
||||
}
|
||||
|
||||
func generateOTP(length int) string {
|
||||
rand.Seed(uint64(time.Now().Nanosecond()))
|
||||
digits := "0123456789"
|
||||
otp := ""
|
||||
for i := 0; i < length; i++ {
|
||||
otp += string(digits[rand.Intn(len(digits))])
|
||||
}
|
||||
return otp
|
||||
}
|
||||
@ -6,6 +6,7 @@ import (
|
||||
"enaklo-pos-be/internal/services/balance"
|
||||
"enaklo-pos-be/internal/services/discovery"
|
||||
service "enaklo-pos-be/internal/services/license"
|
||||
"enaklo-pos-be/internal/services/member"
|
||||
"enaklo-pos-be/internal/services/order"
|
||||
"enaklo-pos-be/internal/services/oss"
|
||||
"enaklo-pos-be/internal/services/partner"
|
||||
@ -45,6 +46,7 @@ type ServiceManagerImpl struct {
|
||||
OrderV2Svc orderSvc.Service
|
||||
CustomerV2Svc customerSvc.Service
|
||||
ProductV2Svc productSvc.Service
|
||||
MemberRegistrationSvc member.RegistrationService
|
||||
}
|
||||
|
||||
func NewServiceManagerImpl(cfg *config.Config, repo *repository.RepoManagerImpl) *ServiceManagerImpl {
|
||||
@ -68,6 +70,8 @@ func NewServiceManagerImpl(cfg *config.Config, repo *repository.RepoManagerImpl)
|
||||
Balance: balance.NewBalanceService(repo.Wallet, repo.Trx, repo.Crypto, &cfg.Withdraw, repo.Transaction),
|
||||
DiscoverService: discovery.NewDiscoveryService(repo.Site, cfg.Discovery, repo.Product),
|
||||
OrderV2Svc: orderSvc.New(repo.OrderRepo, productSvcV2, custSvcV2, repo.TransactionRepo, repo.Crypto, &cfg.Order, repo.EmailService),
|
||||
MemberRegistrationSvc: member.NewMemberRegistrationService(repo.MemberRepository, repo.EmailService, custSvcV2),
|
||||
CustomerV2Svc: custSvcV2,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -5,6 +5,7 @@ import (
|
||||
"enaklo-pos-be/internal/common/mycontext"
|
||||
"enaklo-pos-be/internal/constants"
|
||||
"enaklo-pos-be/internal/entity"
|
||||
"enaklo-pos-be/internal/utils"
|
||||
"github.com/pkg/errors"
|
||||
"go.uber.org/zap"
|
||||
"strings"
|
||||
@ -16,12 +17,16 @@ type Repository interface {
|
||||
FindByPhone(ctx mycontext.Context, phone string) (*entity.Customer, error)
|
||||
FindByEmail(ctx mycontext.Context, email string) (*entity.Customer, error)
|
||||
AddPoints(ctx mycontext.Context, id int64, points int) error
|
||||
FindSequence(ctx mycontext.Context, partnerID int64) (int64, error)
|
||||
GetAllCustomers(ctx mycontext.Context, req entity.MemberSearch) (entity.MemberList, int, error)
|
||||
}
|
||||
|
||||
type Service interface {
|
||||
ResolveCustomer(ctx mycontext.Context, req *entity.CustomerResolutionRequest) (int64, error)
|
||||
AddPoints(ctx mycontext.Context, customerID int64, points int) error
|
||||
GetCustomer(ctx mycontext.Context, id int64) (*entity.Customer, error)
|
||||
CustomerCheck(ctx mycontext.Context, req *entity.CustomerResolutionRequest) (*entity.CustomerCheckResponse, error)
|
||||
GetAllCustomers(ctx mycontext.Context, req *entity.MemberSearch) (*entity.MemberList, int, error)
|
||||
}
|
||||
|
||||
type customerSvc struct {
|
||||
@ -76,6 +81,11 @@ func (s *customerSvc) ResolveCustomer(ctx mycontext.Context, req *entity.Custome
|
||||
return 0, errors.New("customer name is required to create a new customer")
|
||||
}
|
||||
|
||||
lastSeq, err := s.repo.FindSequence(ctx, *ctx.GetPartnerID())
|
||||
if err != nil {
|
||||
return 0, errors.New("failed to resolve customer sequence")
|
||||
}
|
||||
|
||||
newCustomer := &entity.Customer{
|
||||
Name: req.Name,
|
||||
Email: req.Email,
|
||||
@ -83,6 +93,8 @@ func (s *customerSvc) ResolveCustomer(ctx mycontext.Context, req *entity.Custome
|
||||
Points: 0,
|
||||
CreatedAt: constants.TimeNow(),
|
||||
UpdatedAt: constants.TimeNow(),
|
||||
CustomerID: utils.GenerateMemberID(ctx, *ctx.GetPartnerID(), lastSeq),
|
||||
BirthDate: req.BirthDate,
|
||||
}
|
||||
|
||||
customer, err := s.repo.Create(ctx, newCustomer)
|
||||
@ -115,3 +127,78 @@ func (s *customerSvc) GetCustomer(ctx mycontext.Context, id int64) (*entity.Cust
|
||||
|
||||
return customer, nil
|
||||
}
|
||||
|
||||
func (s *customerSvc) CustomerCheck(ctx mycontext.Context, req *entity.CustomerResolutionRequest) (*entity.CustomerCheckResponse, error) {
|
||||
logger.ContextLogger(ctx).Info("checking customer existence before registration",
|
||||
zap.String("email", req.Email),
|
||||
zap.String("phone", req.PhoneNumber))
|
||||
|
||||
if req.Email == "" && req.PhoneNumber == "" {
|
||||
return nil, errors.New("email dan phone number is mandatory")
|
||||
}
|
||||
|
||||
response := &entity.CustomerCheckResponse{
|
||||
Exists: false,
|
||||
Customer: nil,
|
||||
}
|
||||
|
||||
if req.PhoneNumber != "" {
|
||||
customer, err := s.repo.FindByPhone(ctx, req.PhoneNumber)
|
||||
if err != nil {
|
||||
if !strings.Contains(err.Error(), "not found") {
|
||||
logger.ContextLogger(ctx).Error("error checking customer by phone", zap.Error(err))
|
||||
return nil, errors.Wrap(err, "failed to find customer by phone")
|
||||
}
|
||||
} else {
|
||||
logger.ContextLogger(ctx).Info("found existing customer by phone",
|
||||
zap.Int64("customerId", customer.ID))
|
||||
|
||||
return &entity.CustomerCheckResponse{
|
||||
Exists: true,
|
||||
Customer: customer,
|
||||
Message: "Customer already exists with this phone number",
|
||||
}, nil
|
||||
}
|
||||
}
|
||||
|
||||
if req.Email != "" {
|
||||
customer, err := s.repo.FindByEmail(ctx, req.Email)
|
||||
if err != nil {
|
||||
if !strings.Contains(err.Error(), "not found") {
|
||||
logger.ContextLogger(ctx).Error("error checking customer by email", zap.Error(err))
|
||||
return nil, errors.Wrap(err, "failed to find customer by email")
|
||||
}
|
||||
} else {
|
||||
logger.ContextLogger(ctx).Info("found existing customer by email",
|
||||
zap.Int64("customerId", customer.ID))
|
||||
|
||||
return &entity.CustomerCheckResponse{
|
||||
Exists: true,
|
||||
Customer: customer,
|
||||
Message: "Customer already exists with this email",
|
||||
}, nil
|
||||
}
|
||||
}
|
||||
|
||||
return response, nil
|
||||
}
|
||||
|
||||
func (s *customerSvc) GetAllCustomers(ctx mycontext.Context, req *entity.MemberSearch) (*entity.MemberList, int, error) {
|
||||
if req.Limit <= 0 {
|
||||
req.Limit = 10
|
||||
}
|
||||
if req.Offset < 0 {
|
||||
req.Offset = 0
|
||||
}
|
||||
|
||||
customers, totalCount, err := s.repo.GetAllCustomers(ctx, *req)
|
||||
if err != nil {
|
||||
logger.ContextLogger(ctx).Error("failed to retrieve customers",
|
||||
zap.Error(err),
|
||||
zap.String("search", req.Search),
|
||||
)
|
||||
return nil, 0, errors.Wrap(err, "failed to get customers")
|
||||
}
|
||||
|
||||
return &customers, totalCount, nil
|
||||
}
|
||||
|
||||
1
internal/services/v2/member/member.go
Normal file
1
internal/services/v2/member/member.go
Normal file
@ -0,0 +1 @@
|
||||
package member
|
||||
@ -136,6 +136,9 @@ func (s *orderSvc) sendTransactionReceipt(ctx mycontext.Context, order *entity.O
|
||||
|
||||
emailData := map[string]interface{}{
|
||||
"UserName": customer.Name,
|
||||
"PointsName": "PoinLo",
|
||||
"PointsBalance": "20",
|
||||
"RedeemLink": "enaklo.co.id",
|
||||
"BranchName": branchName,
|
||||
"TransactionNumber": order.ID,
|
||||
"TransactionDate": transactionDate,
|
||||
@ -148,9 +151,9 @@ func (s *orderSvc) sendTransactionReceipt(ctx mycontext.Context, order *entity.O
|
||||
return s.notification.SendEmailTransactional(ctx, entity.SendEmailNotificationParam{
|
||||
Sender: "noreply@enaklo.co.id",
|
||||
Recipient: customer.Email,
|
||||
Subject: "Enaklo - Resi Pembelian",
|
||||
TemplateName: "transaction_receipt",
|
||||
TemplatePath: "templates/transaction_receipt.html",
|
||||
Subject: "Enaklo - Membership Statement",
|
||||
TemplateName: "monthly_points",
|
||||
TemplatePath: "templates/monthly_points.html",
|
||||
Data: emailData,
|
||||
})
|
||||
}
|
||||
|
||||
14
internal/utils/member_generator.go
Normal file
14
internal/utils/member_generator.go
Normal file
@ -0,0 +1,14 @@
|
||||
package utils
|
||||
|
||||
import (
|
||||
"enaklo-pos-be/internal/common/mycontext"
|
||||
"fmt"
|
||||
"time"
|
||||
)
|
||||
|
||||
func GenerateMemberID(ctx mycontext.Context, branchID, sequence int64) string {
|
||||
now := time.Now()
|
||||
yearMonth := now.Format("200601")
|
||||
|
||||
return fmt.Sprintf("%s%04d%04d", yearMonth, branchID, sequence)
|
||||
}
|
||||
217
templates/member_registration_otp.html
Normal file
217
templates/member_registration_otp.html
Normal file
@ -0,0 +1,217 @@
|
||||
<!doctype html>
|
||||
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:v="urn:schemas-microsoft-com:vml" xmlns:o="urn:schemas-microsoft-com:office:office">
|
||||
|
||||
<head>
|
||||
<title>Kode Verifikasi Pendaftaran Member Enaklo</title>
|
||||
<meta http-equiv="X-UA-Compatible" content="IE=edge">
|
||||
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<style type="text/css">
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
-webkit-text-size-adjust: 100%;
|
||||
-ms-text-size-adjust: 100%;
|
||||
background-color: #f1f0f7;
|
||||
}
|
||||
|
||||
table,
|
||||
td {
|
||||
border-collapse: collapse;
|
||||
mso-table-lspace: 0pt;
|
||||
mso-table-rspace: 0pt;
|
||||
}
|
||||
|
||||
img {
|
||||
border: 0;
|
||||
height: auto;
|
||||
line-height: 100%;
|
||||
outline: none;
|
||||
text-decoration: none;
|
||||
-ms-interpolation-mode: bicubic;
|
||||
}
|
||||
|
||||
p {
|
||||
display: block;
|
||||
margin: 13px 0;
|
||||
}
|
||||
|
||||
.mj-column-per-100 {
|
||||
width: 100% !important;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.content {
|
||||
background-color: #ffffff;
|
||||
margin: 0px auto;
|
||||
max-width: 600px;
|
||||
padding: 30px;
|
||||
border-radius: 10px;
|
||||
box-shadow: 0px 4px 8px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.header {
|
||||
font-family: Ubuntu, Helvetica, Arial, sans-serif;
|
||||
font-size: 24px;
|
||||
line-height: 28px;
|
||||
color: #f46f02;
|
||||
text-align: center;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-family: Ubuntu, Helvetica, Arial, sans-serif;
|
||||
font-size: 22px;
|
||||
line-height: 26px;
|
||||
color: #000000;
|
||||
text-align: center;
|
||||
margin-bottom: 20px;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.text {
|
||||
font-family: Ubuntu, Helvetica, Arial, sans-serif;
|
||||
font-size: 16px;
|
||||
line-height: 24px;
|
||||
color: #333333;
|
||||
text-align: center;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.otp-code {
|
||||
display: block;
|
||||
width: fit-content;
|
||||
margin: 25px auto;
|
||||
background-color: #f5f5f5;
|
||||
color: #000000;
|
||||
text-align: center;
|
||||
padding: 15px 40px;
|
||||
border-radius: 8px;
|
||||
font-family: monospace;
|
||||
font-size: 32px;
|
||||
line-height: 36px;
|
||||
font-weight: bold;
|
||||
letter-spacing: 6px;
|
||||
border: 1px dashed #cccccc;
|
||||
}
|
||||
|
||||
.footer {
|
||||
font-family: Ubuntu, Helvetica, Arial, sans-serif;
|
||||
font-size: 12px;
|
||||
line-height: 18px;
|
||||
text-align: center;
|
||||
color: #808080;
|
||||
margin-top: 30px;
|
||||
}
|
||||
|
||||
.divider {
|
||||
border-top: solid 1px #e0e0e0;
|
||||
margin: 25px auto;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.logo {
|
||||
display: block;
|
||||
margin: 0 auto 25px;
|
||||
width: 150px;
|
||||
}
|
||||
|
||||
.info-box {
|
||||
background-color: #e3f2fd;
|
||||
border-left: 4px solid #2196f3;
|
||||
padding: 15px;
|
||||
margin: 20px 0;
|
||||
font-family: Ubuntu, Helvetica, Arial, sans-serif;
|
||||
font-size: 14px;
|
||||
line-height: 20px;
|
||||
color: #0d47a1;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.expiry {
|
||||
font-family: Ubuntu, Helvetica, Arial, sans-serif;
|
||||
font-size: 14px;
|
||||
color: #d32f2f;
|
||||
text-align: center;
|
||||
margin-bottom: 20px;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.benefits {
|
||||
background-color: #f9f9f9;
|
||||
border-radius: 8px;
|
||||
padding: 15px;
|
||||
margin: 20px 0;
|
||||
}
|
||||
|
||||
.benefit-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin: 10px 0;
|
||||
font-family: Ubuntu, Helvetica, Arial, sans-serif;
|
||||
font-size: 14px;
|
||||
color: #333333;
|
||||
}
|
||||
|
||||
.check-icon {
|
||||
color: #4caf50;
|
||||
font-size: 16px;
|
||||
margin-right: 10px;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div style="padding: 50px; background-color: #f1f0f7;">
|
||||
<div class="content">
|
||||
<img src="https://res.cloudinary.com/dl0wpumax/image/upload/c_thumb,w_200,g_face/v1741363977/61747686_5_vtz0n4.png" alt="Enaklo Logo" class="logo">
|
||||
<div class="title">Kode Verifikasi Pendaftaran Member</div>
|
||||
<div class="text">
|
||||
Hai {{ .UserName }},<br><br>
|
||||
Terima kasih telah mendaftar sebagai member Enaklo. Berikan kode verifikasi berikut kepada staf kasir kami untuk menyelesaikan pendaftaran Anda:
|
||||
</div>
|
||||
<div class="otp-code">{{ .OTPCode }}</div>
|
||||
<div class="expiry">Kode ini berlaku selama 10 menit</div>
|
||||
|
||||
<div class="info-box">
|
||||
<strong>Cara Menyelesaikan Pendaftaran:</strong><br>
|
||||
1. Tunjukkan email ini kepada staf kasir Enaklo<br>
|
||||
2. Staf akan memverifikasi identitas Anda<br>
|
||||
3. Staf akan memasukkan kode OTP ini ke sistem POS<br>
|
||||
4. Pendaftaran member Anda akan segera aktif!
|
||||
</div>
|
||||
|
||||
<div class="text">
|
||||
Dengan menjadi member Enaklo, Anda akan menikmati berbagai keuntungan eksklusif:
|
||||
</div>
|
||||
|
||||
<div class="benefits">
|
||||
<div class="benefit-item">
|
||||
<span class="check-icon">✓</span> Kumpulkan poin dengan setiap pembelian
|
||||
</div>
|
||||
<div class="benefit-item">
|
||||
<span class="check-icon">✓</span> Diskon khusus member pada menu pilihan
|
||||
</div>
|
||||
<div class="benefit-item">
|
||||
<span class="check-icon">✓</span> Penawaran eksklusif saat ulang tahun Anda
|
||||
</div>
|
||||
<div class="benefit-item">
|
||||
<span class="check-icon">✓</span> Akses awal ke menu baru dan promo spesial
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="divider"></div>
|
||||
<div class="text" style="font-size: 14px; color: #666666;">
|
||||
Jika Anda tidak sedang mendaftar sebagai member Enaklo, abaikan email ini atau hubungi tim support kami segera.
|
||||
</div>
|
||||
<div class="footer">
|
||||
Email ini dikirim secara otomatis. Mohon jangan membalas email ini. <br>
|
||||
Butuh bantuan? Hubungi tim support kami di <a href="mailto:support@enaklo.com" style="color: #f46f02; text-decoration: none;">support@enaklo.com</a>.
|
||||
<br><br>
|
||||
© 2025 Enaklo. All rights reserved.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
331
templates/welcome_member.html
Normal file
331
templates/welcome_member.html
Normal file
@ -0,0 +1,331 @@
|
||||
<!doctype html>
|
||||
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:v="urn:schemas-microsoft-com:vml" xmlns:o="urn:schemas-microsoft-com:office:office">
|
||||
|
||||
<head>
|
||||
<title>Selamat Datang di Enaklo Membership</title>
|
||||
<meta http-equiv="X-UA-Compatible" content="IE=edge">
|
||||
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<style type="text/css">
|
||||
/* CLIENT-SPECIFIC STYLES */
|
||||
body, table, td, a { -webkit-text-size-adjust: 100%; -ms-text-size-adjust: 100%; }
|
||||
table, td { mso-table-lspace: 0pt; mso-table-rspace: 0pt; }
|
||||
img { -ms-interpolation-mode: bicubic; }
|
||||
|
||||
/* RESET STYLES */
|
||||
img { border: 0; height: auto; line-height: 100%; outline: none; text-decoration: none; }
|
||||
table { border-collapse: collapse !important; }
|
||||
body { height: 100% !important; margin: 0 !important; padding: 0 !important; width: 100% !important; }
|
||||
|
||||
/* GENERAL STYLES */
|
||||
body {
|
||||
font-family: Arial, Helvetica, sans-serif;
|
||||
background-color: #f1f0f7;
|
||||
}
|
||||
|
||||
.wrapper {
|
||||
padding: 20px;
|
||||
background-color: #f1f0f7;
|
||||
max-width: 600px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.content {
|
||||
background-color: #ffffff;
|
||||
padding: 30px;
|
||||
border-radius: 10px;
|
||||
box-shadow: 0px 4px 8px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.logo {
|
||||
display: block;
|
||||
margin: 0 auto 25px;
|
||||
width: 150px;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 22px;
|
||||
line-height: 26px;
|
||||
color: #000000;
|
||||
text-align: center;
|
||||
margin-bottom: 20px;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.text {
|
||||
font-size: 16px;
|
||||
line-height: 24px;
|
||||
color: #333333;
|
||||
text-align: center;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
/* MEMBER CARD STYLES */
|
||||
.member-card {
|
||||
background: linear-gradient(135deg, #dc0404 0%, #d90000 100%);
|
||||
padding: 20px;
|
||||
border-radius: 10px;
|
||||
color: white !important;
|
||||
margin: 20px auto;
|
||||
width: 80%;
|
||||
max-width: 300px;
|
||||
box-shadow: 0 4px 8px rgba(0,0,0,0.2);
|
||||
}
|
||||
|
||||
.member-name {
|
||||
font-size: 18px;
|
||||
font-weight: bold;
|
||||
margin-bottom: 5px;
|
||||
color: white !important;
|
||||
}
|
||||
|
||||
.member-id {
|
||||
font-size: 14px;
|
||||
margin-bottom: 15px;
|
||||
opacity: 0.9;
|
||||
color: white !important;
|
||||
}
|
||||
|
||||
.member-since {
|
||||
font-size: 12px;
|
||||
margin-top: 15px;
|
||||
text-align: right;
|
||||
color: white !important;
|
||||
}
|
||||
|
||||
.member-name {
|
||||
font-size: 18px;
|
||||
font-weight: bold;
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
|
||||
.member-id {
|
||||
font-size: 14px;
|
||||
margin-bottom: 15px;
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.card-divider {
|
||||
border-top: solid 1px rgba(255,255,255,0.3);
|
||||
margin: 10px 0;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.member-since {
|
||||
font-size: 12px;
|
||||
margin-top: 15px;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
/* BENEFITS SECTION STYLES */
|
||||
.benefits-container {
|
||||
background-color: #f9f9f9;
|
||||
border-radius: 8px;
|
||||
padding: 20px;
|
||||
margin: 20px 0;
|
||||
}
|
||||
|
||||
.benefit-item {
|
||||
display: block;
|
||||
margin: 15px 0;
|
||||
text-align: left;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.number-circle {
|
||||
background-color: #dc0404;
|
||||
color: white;
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
border-radius: 50%;
|
||||
text-align: center;
|
||||
line-height: 30px;
|
||||
font-weight: bold;
|
||||
float: left;
|
||||
margin-right: 15px;
|
||||
margin-top: 3px;
|
||||
}
|
||||
|
||||
.benefit-content {
|
||||
display: block;
|
||||
margin-left: 45px;
|
||||
}
|
||||
|
||||
.benefit-title {
|
||||
font-weight: bold;
|
||||
display: block;
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
|
||||
.benefit-description {
|
||||
font-size: 14px;
|
||||
color: #333333;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
/* BUTTON STYLES */
|
||||
.cta-button {
|
||||
display: block;
|
||||
width: 200px;
|
||||
margin: 25px auto;
|
||||
padding: 12px 24px;
|
||||
background-color: #dc0404;
|
||||
color: #ffffff;
|
||||
font-size: 16px;
|
||||
font-weight: bold;
|
||||
text-align: center;
|
||||
text-decoration: none;
|
||||
border-radius: 5px;
|
||||
}
|
||||
|
||||
.divider {
|
||||
border-top: solid 1px #e0e0e0;
|
||||
margin: 25px auto;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.footer {
|
||||
font-size: 12px;
|
||||
line-height: 18px;
|
||||
text-align: center;
|
||||
color: #808080;
|
||||
margin-top: 30px;
|
||||
}
|
||||
|
||||
/* FOR MOBILE */
|
||||
@media screen and (max-width: 600px) {
|
||||
.wrapper {
|
||||
padding: 10px !important;
|
||||
}
|
||||
.content {
|
||||
padding: 20px !important;
|
||||
}
|
||||
.member-card {
|
||||
width: 90% !important;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div class="wrapper">
|
||||
<table width="100%" cellpadding="0" cellspacing="0" border="0">
|
||||
<tr>
|
||||
<td>
|
||||
<div class="content">
|
||||
<!-- Logo -->
|
||||
<img src="https://res.cloudinary.com/dl0wpumax/image/upload/c_thumb,w_200,g_face/v1741363977/61747686_5_vtz0n4.png" width="80" alt="Enaklo Logo" class="logo">
|
||||
|
||||
<!-- Title -->
|
||||
<div class="title">Selamat Datang di Program Membership Enaklo!</div>
|
||||
|
||||
<!-- Greeting -->
|
||||
<div class="text">
|
||||
Halo {{ .UserName }},<br><br>
|
||||
Terima kasih telah bergabung dengan program membership Enaklo. Kami senang Anda menjadi bagian dari keluarga kami!
|
||||
</div>
|
||||
|
||||
<table width="100%" cellpadding="0" cellspacing="0" border="0">
|
||||
<tr>
|
||||
<td align="center">
|
||||
<table class="member-card" width="300" cellpadding="0" cellspacing="0" border="0" style="background: linear-gradient(135deg, #dc0404 0%, #d90000 100%); border-radius: 10px; color: white !important;">
|
||||
<tr>
|
||||
<td style="padding: 20px;">
|
||||
<div class="member-name" style="font-size: 18px; font-weight: bold; margin-bottom: 5px; color: white !important;">{{ .UserName }}</div>
|
||||
<div class="member-id" style="font-size: 14px; margin-bottom: 15px; opacity: 0.9; color: white !important;">ID Member: {{ .MemberID }}</div>
|
||||
<div class="card-divider" style="border-top: solid 1px rgba(255,255,255,0.3); margin: 10px 0; width: 100%;"></div>
|
||||
<table width="100%" cellpadding="0" cellspacing="0" border="0">
|
||||
<tr>
|
||||
<td>
|
||||
<div style="font-size: 12px; opacity: 0.9; color: white !important;">{{ .PointsName }}</div>
|
||||
<div style="font-size: 22px; font-weight: bold; color: white !important;">{{ .PointsBalance }}</div>
|
||||
</td>
|
||||
<td align="right">
|
||||
<img src="https://res.cloudinary.com/dl0wpumax/image/upload/c_thumb,w_200,g_face/v1741363977/61747686_5_vtz0n4.png" width="40" alt="Logo">
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
<div class="member-since" style="font-size: 12px; margin-top: 15px; text-align: right; color: white !important;">Member sejak {{ .CurrentDate }}</div>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<div class="text">
|
||||
Sebagai member Enaklo, Anda berhak mendapatkan berbagai keuntungan eksklusif.
|
||||
</div>
|
||||
|
||||
<!-- Benefits Section with Fixed Numbered Circles -->
|
||||
<div class="benefits-container">
|
||||
<!-- Benefit 1 -->
|
||||
<div class="benefit-item">
|
||||
<div class="number-circle">1</div>
|
||||
<div class="benefit-content">
|
||||
<span class="benefit-title">Kumpulkan {{ .PointsName }}</span>
|
||||
<span class="benefit-description">Dapatkan {{ .PointsName }} setiap kali Anda bertransaksi di Enaklo. Setiap Rp 1.000 = 1 {{ .PointsName }}.</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Benefit 2 -->
|
||||
<div class="benefit-item">
|
||||
<div class="number-circle">2</div>
|
||||
<div class="benefit-content">
|
||||
<span class="benefit-title">Tukarkan dengan Reward Menarik</span>
|
||||
<span class="benefit-description">{{ .PointsName }} Anda dapat ditukarkan dengan berbagai menu favorit atau diskon khusus.</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Benefit 3 -->
|
||||
<div class="benefit-item">
|
||||
<div class="number-circle">3</div>
|
||||
<div class="benefit-content">
|
||||
<span class="benefit-title">Penawaran Eksklusif</span>
|
||||
<span class="benefit-description">Dapatkan akses ke penawaran dan promosi khusus yang hanya tersedia untuk member.</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Benefit 4 -->
|
||||
<div class="benefit-item">
|
||||
<div class="number-circle">4</div>
|
||||
<div class="benefit-content">
|
||||
<span class="benefit-title">Kejutan di Hari Spesial</span>
|
||||
<span class="benefit-description">Dapatkan hadiah spesial di hari ulang tahun Anda dan acara spesial lainnya.</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="text">
|
||||
Untuk melihat {{ .PointsName }} Anda dan menukarkan hadiah, kunjungi halaman rewards kami.
|
||||
</div>
|
||||
|
||||
<!-- CTA Button - Center aligned and proper width -->
|
||||
<table width="100%" cellpadding="0" cellspacing="0" border="0">
|
||||
<tr>
|
||||
<td align="center">
|
||||
<a href="{{ .RedeemLink }}" class="cta-button" style="display: inline-block; color: #ffffff; text-decoration: none;">Lihat Rewards</a>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<div class="divider"></div>
|
||||
|
||||
<div class="text" style="font-size: 14px; color: #666666;">
|
||||
Tunjukkan kartu member digital Anda (di email ini) atau sebutkan nomor telepon Anda saat bertransaksi di Enaklo untuk mengumpulkan {{ .PointsName }}.
|
||||
</div>
|
||||
|
||||
<div class="footer">
|
||||
Email ini dikirim secara otomatis. Mohon jangan membalas email ini. <br>
|
||||
Butuh bantuan? Hubungi tim support kami di <a href="mailto:support@enaklo.co.id" style="color: #d90000; text-decoration: none;">support@enaklo.co.id</a>.
|
||||
<br><br>
|
||||
© 2025 Enaklo. All rights reserved.
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
Loading…
x
Reference in New Issue
Block a user