fix: align dev auth and bootstrap superadmin
This commit is contained in:
parent
3ca9f50d0c
commit
b00d0fe99c
17 changed files with 612 additions and 105 deletions
|
|
@ -8,10 +8,6 @@ BACKEND_PORT=8214
|
||||||
|
|
||||||
# Database Configuration
|
# Database Configuration
|
||||||
DATABASE_URL=postgres://postgres:123@localhost:55432/saveinmed?sslmode=disable
|
DATABASE_URL=postgres://postgres:123@localhost:55432/saveinmed?sslmode=disable
|
||||||
ADMIN_NAME=Admin Master
|
|
||||||
ADMIN_USERNAME=admin
|
|
||||||
ADMIN_EMAIL=andre.fr93@gmail.com
|
|
||||||
ADMIN_PASSWORD=teste1234
|
|
||||||
|
|
||||||
# JWT Authentication
|
# JWT Authentication
|
||||||
JWT_SECRET=your-secret-key-here
|
JWT_SECRET=your-secret-key-here
|
||||||
|
|
|
||||||
8
backend/.env.template
Normal file
8
backend/.env.template
Normal file
|
|
@ -0,0 +1,8 @@
|
||||||
|
STORE_CORS=https://dev.saveinmed.com.br,https://mkt-dev.saveinmed.com.br,https://docs.medusajs.com
|
||||||
|
ADMIN_CORS=https://back-dev.saveinmed.com.br,https://dev.saveinmed.com.br,https://docs.medusajs.com
|
||||||
|
AUTH_CORS=https://dev.saveinmed.com.br,https://mkt-dev.saveinmed.com.br,https://back-dev.saveinmed.com.br,https://docs.medusajs.com
|
||||||
|
REDIS_URL=redis://localhost:6379
|
||||||
|
JWT_SECRET=supersecret
|
||||||
|
COOKIE_SECRET=supersecret
|
||||||
|
DATABASE_URL=postgres://coolify:Shared#User#Password#123!4@postgres-db:5432/saveinmed?sslmode=disable
|
||||||
|
DB_NAME=saveinmed
|
||||||
|
|
@ -105,11 +105,9 @@ JWT_SECRET=your-secret-key
|
||||||
JWT_EXPIRES_IN=24h
|
JWT_EXPIRES_IN=24h
|
||||||
PASSWORD_PEPPER=your-pepper
|
PASSWORD_PEPPER=your-pepper
|
||||||
|
|
||||||
# Admin Seeding (criado automaticamente na inicialização)
|
# Superadmin Bootstrap
|
||||||
ADMIN_NAME=Administrator
|
# O backend cria automaticamente um superadmin padrao ao subir.
|
||||||
ADMIN_USERNAME=admin
|
# As credenciais iniciais sao geradas e registradas no log apenas na primeira criacao.
|
||||||
ADMIN_EMAIL=admin@saveinmed.com
|
|
||||||
ADMIN_PASSWORD=admin123
|
|
||||||
|
|
||||||
# CORS
|
# CORS
|
||||||
CORS_ORIGINS=*
|
CORS_ORIGINS=*
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,13 @@ import (
|
||||||
"github.com/saveinmed/backend-go/internal/domain"
|
"github.com/saveinmed/backend-go/internal/domain"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
seederSuperadminName = "SaveInMed Superadmin"
|
||||||
|
seederSuperadminUsername = "superadmin"
|
||||||
|
seederSuperadminEmail = "superadmin@saveinmed.com"
|
||||||
|
seederSuperadminPassword = "sim-seeder-superadmin"
|
||||||
|
)
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
cfg, err := config.Load()
|
cfg, err := config.Load()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
@ -58,23 +65,24 @@ func cleanDB(ctx context.Context, db *sqlx.DB) {
|
||||||
}
|
}
|
||||||
|
|
||||||
func seedData(ctx context.Context, db *sqlx.DB, cfg config.Config) {
|
func seedData(ctx context.Context, db *sqlx.DB, cfg config.Config) {
|
||||||
// 1. Seed Admin
|
// 1. Seed platform superadmin
|
||||||
adminCompanyID := uuid.Must(uuid.NewV7())
|
adminCompanyID := uuid.Must(uuid.NewV7())
|
||||||
createCompany(ctx, db, &domain.Company{
|
createCompany(ctx, db, &domain.Company{
|
||||||
ID: adminCompanyID,
|
ID: adminCompanyID,
|
||||||
CNPJ: "00000000000000",
|
CNPJ: "00000000000000",
|
||||||
CorporateName: "SaveInMed Admin",
|
CorporateName: "SaveInMed Platform",
|
||||||
Category: "admin",
|
Category: "platform",
|
||||||
LicenseNumber: "ADMIN",
|
LicenseNumber: "SUPERADMIN",
|
||||||
IsVerified: true,
|
IsVerified: true,
|
||||||
})
|
})
|
||||||
createUser(ctx, db, &domain.User{
|
createUser(ctx, db, &domain.User{
|
||||||
CompanyID: adminCompanyID,
|
CompanyID: adminCompanyID,
|
||||||
Role: "Admin",
|
Role: "superadmin",
|
||||||
Name: cfg.AdminName,
|
Name: seederSuperadminName,
|
||||||
Username: cfg.AdminUsername,
|
Username: seederSuperadminUsername,
|
||||||
Email: cfg.AdminEmail,
|
Email: seederSuperadminEmail,
|
||||||
}, cfg.AdminPassword, cfg.PasswordPepper)
|
Superadmin: true,
|
||||||
|
}, seederSuperadminPassword, cfg.PasswordPepper)
|
||||||
|
|
||||||
// 2. Distributors (Sellers - SP Center)
|
// 2. Distributors (Sellers - SP Center)
|
||||||
distributor1ID := uuid.Must(uuid.NewV7())
|
distributor1ID := uuid.Must(uuid.NewV7())
|
||||||
|
|
|
||||||
|
|
@ -25,10 +25,6 @@ type Config struct {
|
||||||
CORSOrigins []string
|
CORSOrigins []string
|
||||||
BackendHost string
|
BackendHost string
|
||||||
SwaggerSchemes []string
|
SwaggerSchemes []string
|
||||||
AdminName string
|
|
||||||
AdminUsername string
|
|
||||||
AdminEmail string
|
|
||||||
AdminPassword string
|
|
||||||
MercadoPagoPublicKey string
|
MercadoPagoPublicKey string
|
||||||
MapboxAccessToken string
|
MapboxAccessToken string
|
||||||
}
|
}
|
||||||
|
|
@ -52,10 +48,6 @@ func Load() (*Config, error) {
|
||||||
CORSOrigins: getEnvStringSlice("CORS_ORIGINS", []string{"*"}),
|
CORSOrigins: getEnvStringSlice("CORS_ORIGINS", []string{"*"}),
|
||||||
BackendHost: getEnv("BACKEND_HOST", ""),
|
BackendHost: getEnv("BACKEND_HOST", ""),
|
||||||
SwaggerSchemes: getEnvStringSlice("SWAGGER_SCHEMES", []string{"http"}),
|
SwaggerSchemes: getEnvStringSlice("SWAGGER_SCHEMES", []string{"http"}),
|
||||||
AdminName: getEnv("ADMIN_NAME", "Administrator"),
|
|
||||||
AdminUsername: getEnv("ADMIN_USERNAME", "admin"),
|
|
||||||
AdminEmail: getEnv("ADMIN_EMAIL", "admin@saveinmed.com"),
|
|
||||||
AdminPassword: getEnv("ADMIN_PASSWORD", "admin123"),
|
|
||||||
MercadoPagoPublicKey: getEnv("MERCADOPAGO_PUBLIC_KEY", "TEST-PUBLIC-KEY"),
|
MercadoPagoPublicKey: getEnv("MERCADOPAGO_PUBLIC_KEY", "TEST-PUBLIC-KEY"),
|
||||||
MapboxAccessToken: getEnv("MAPBOX_ACCESS_TOKEN", ""),
|
MapboxAccessToken: getEnv("MAPBOX_ACCESS_TOKEN", ""),
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,7 @@ func TestLoadDefaults(t *testing.T) {
|
||||||
envVars := []string{
|
envVars := []string{
|
||||||
"APP_NAME", "BACKEND_PORT", "DATABASE_URL",
|
"APP_NAME", "BACKEND_PORT", "DATABASE_URL",
|
||||||
"MERCADOPAGO_BASE_URL", "MARKETPLACE_COMMISSION", "JWT_SECRET", "JWT_EXPIRES_IN",
|
"MERCADOPAGO_BASE_URL", "MARKETPLACE_COMMISSION", "JWT_SECRET", "JWT_EXPIRES_IN",
|
||||||
"PASSWORD_PEPPER", "CORS_ORIGINS", "ADMIN_NAME", "ADMIN_USERNAME", "ADMIN_EMAIL", "ADMIN_PASSWORD",
|
"PASSWORD_PEPPER", "CORS_ORIGINS",
|
||||||
}
|
}
|
||||||
origEnvs := make(map[string]string)
|
origEnvs := make(map[string]string)
|
||||||
for _, key := range envVars {
|
for _, key := range envVars {
|
||||||
|
|
@ -49,18 +49,6 @@ func TestLoadDefaults(t *testing.T) {
|
||||||
if len(cfg.CORSOrigins) != 1 || cfg.CORSOrigins[0] != "*" {
|
if len(cfg.CORSOrigins) != 1 || cfg.CORSOrigins[0] != "*" {
|
||||||
t.Errorf("expected CORSOrigins ['*'], got %v", cfg.CORSOrigins)
|
t.Errorf("expected CORSOrigins ['*'], got %v", cfg.CORSOrigins)
|
||||||
}
|
}
|
||||||
if cfg.AdminName != "Administrator" {
|
|
||||||
t.Errorf("expected AdminName 'Administrator', got '%s'", cfg.AdminName)
|
|
||||||
}
|
|
||||||
if cfg.AdminUsername != "admin" {
|
|
||||||
t.Errorf("expected AdminUsername 'admin', got '%s'", cfg.AdminUsername)
|
|
||||||
}
|
|
||||||
if cfg.AdminEmail != "admin@saveinmed.com" {
|
|
||||||
t.Errorf("expected AdminEmail 'admin@saveinmed.com', got '%s'", cfg.AdminEmail)
|
|
||||||
}
|
|
||||||
if cfg.AdminPassword != "admin123" {
|
|
||||||
t.Errorf("expected AdminPassword 'admin123', got '%s'", cfg.AdminPassword)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestLoadFromEnv(t *testing.T) {
|
func TestLoadFromEnv(t *testing.T) {
|
||||||
|
|
@ -75,11 +63,6 @@ func TestLoadFromEnv(t *testing.T) {
|
||||||
os.Setenv("CORS_ORIGINS", "https://example.com,https://app.example.com")
|
os.Setenv("CORS_ORIGINS", "https://example.com,https://app.example.com")
|
||||||
os.Setenv("BACKEND_HOST", "api.test.local")
|
os.Setenv("BACKEND_HOST", "api.test.local")
|
||||||
os.Setenv("SWAGGER_SCHEMES", "https, http")
|
os.Setenv("SWAGGER_SCHEMES", "https, http")
|
||||||
os.Setenv("ADMIN_NAME", "CustomAdmin")
|
|
||||||
os.Setenv("ADMIN_USERNAME", "customadmin")
|
|
||||||
os.Setenv("ADMIN_EMAIL", "custom@example.com")
|
|
||||||
os.Setenv("ADMIN_PASSWORD", "securepass")
|
|
||||||
|
|
||||||
defer func() {
|
defer func() {
|
||||||
os.Unsetenv("APP_NAME")
|
os.Unsetenv("APP_NAME")
|
||||||
os.Unsetenv("BACKEND_PORT")
|
os.Unsetenv("BACKEND_PORT")
|
||||||
|
|
@ -92,10 +75,6 @@ func TestLoadFromEnv(t *testing.T) {
|
||||||
os.Unsetenv("CORS_ORIGINS")
|
os.Unsetenv("CORS_ORIGINS")
|
||||||
os.Unsetenv("BACKEND_HOST")
|
os.Unsetenv("BACKEND_HOST")
|
||||||
os.Unsetenv("SWAGGER_SCHEMES")
|
os.Unsetenv("SWAGGER_SCHEMES")
|
||||||
os.Unsetenv("ADMIN_NAME")
|
|
||||||
os.Unsetenv("ADMIN_USERNAME")
|
|
||||||
os.Unsetenv("ADMIN_EMAIL")
|
|
||||||
os.Unsetenv("ADMIN_PASSWORD")
|
|
||||||
}()
|
}()
|
||||||
|
|
||||||
cfg, err := Load()
|
cfg, err := Load()
|
||||||
|
|
@ -136,18 +115,6 @@ func TestLoadFromEnv(t *testing.T) {
|
||||||
if len(cfg.SwaggerSchemes) != 2 || cfg.SwaggerSchemes[0] != "https" || cfg.SwaggerSchemes[1] != "http" {
|
if len(cfg.SwaggerSchemes) != 2 || cfg.SwaggerSchemes[0] != "https" || cfg.SwaggerSchemes[1] != "http" {
|
||||||
t.Errorf("expected SwaggerSchemes [https http], got %v", cfg.SwaggerSchemes)
|
t.Errorf("expected SwaggerSchemes [https http], got %v", cfg.SwaggerSchemes)
|
||||||
}
|
}
|
||||||
if cfg.AdminName != "CustomAdmin" {
|
|
||||||
t.Errorf("expected AdminName 'CustomAdmin', got '%s'", cfg.AdminName)
|
|
||||||
}
|
|
||||||
if cfg.AdminUsername != "customadmin" {
|
|
||||||
t.Errorf("expected AdminUsername 'customadmin', got '%s'", cfg.AdminUsername)
|
|
||||||
}
|
|
||||||
if cfg.AdminEmail != "custom@example.com" {
|
|
||||||
t.Errorf("expected AdminEmail 'custom@example.com', got '%s'", cfg.AdminEmail)
|
|
||||||
}
|
|
||||||
if cfg.AdminPassword != "securepass" {
|
|
||||||
t.Errorf("expected AdminPassword 'securepass', got '%s'", cfg.AdminPassword)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestAddr(t *testing.T) {
|
func TestAddr(t *testing.T) {
|
||||||
|
|
|
||||||
|
|
@ -1090,7 +1090,7 @@ func (r *Repository) GetUserByUsername(ctx context.Context, username string) (*d
|
||||||
|
|
||||||
func (r *Repository) GetUserByEmail(ctx context.Context, email string) (*domain.User, error) {
|
func (r *Repository) GetUserByEmail(ctx context.Context, email string) (*domain.User, error) {
|
||||||
var user domain.User
|
var user domain.User
|
||||||
query := `SELECT id, company_id, role, name, username, email, email_verified, password_hash, created_at, updated_at FROM users WHERE email = $1`
|
query := `SELECT id, company_id, role, name, username, email, email_verified, password_hash, superadmin, created_at, updated_at FROM users WHERE email = $1`
|
||||||
if err := r.db.GetContext(ctx, &user, query, email); err != nil {
|
if err := r.db.GetContext(ctx, &user, query, email); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
@ -1101,7 +1101,7 @@ func (r *Repository) UpdateUser(ctx context.Context, user *domain.User) error {
|
||||||
user.UpdatedAt = time.Now().UTC()
|
user.UpdatedAt = time.Now().UTC()
|
||||||
|
|
||||||
query := `UPDATE users
|
query := `UPDATE users
|
||||||
SET company_id = :company_id, role = :role, name = :name, username = :username, email = :email, email_verified = :email_verified, password_hash = :password_hash, updated_at = :updated_at
|
SET company_id = :company_id, role = :role, name = :name, username = :username, email = :email, email_verified = :email_verified, password_hash = :password_hash, superadmin = :superadmin, updated_at = :updated_at
|
||||||
WHERE id = :id`
|
WHERE id = :id`
|
||||||
|
|
||||||
res, err := r.db.NamedExecContext(ctx, query, user)
|
res, err := r.db.NamedExecContext(ctx, query, user)
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,13 @@ import (
|
||||||
"github.com/saveinmed/backend-go/internal/usecase"
|
"github.com/saveinmed/backend-go/internal/usecase"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
bootstrapSuperadminName = "SaveInMed Superadmin"
|
||||||
|
bootstrapSuperadminUsername = "superadmin"
|
||||||
|
bootstrapSuperadminEmail = "superadmin@saveinmed.com"
|
||||||
|
bootstrapSuperadminPassword = "sim-superadmin"
|
||||||
|
)
|
||||||
|
|
||||||
// Server wires the infrastructure and exposes HTTP handlers.
|
// Server wires the infrastructure and exposes HTTP handlers.
|
||||||
type Server struct {
|
type Server struct {
|
||||||
cfg config.Config
|
cfg config.Config
|
||||||
|
|
@ -65,8 +72,7 @@ func New(cfg config.Config) (*Server, error) {
|
||||||
})
|
})
|
||||||
|
|
||||||
auth := middleware.RequireAuth([]byte(cfg.JWTSecret))
|
auth := middleware.RequireAuth([]byte(cfg.JWTSecret))
|
||||||
adminOnly := middleware.RequireAuth([]byte(cfg.JWTSecret), "Admin") // Keep for strict admin routes if any
|
adminOnly := middleware.RequireAuth([]byte(cfg.JWTSecret), "superadmin")
|
||||||
// Allow Admin, Superadmin, Dono, Gerente, and Seller to manage products
|
|
||||||
productManagers := middleware.RequireAuth([]byte(cfg.JWTSecret), "Admin", "superadmin", "Dono", "Gerente", "Seller")
|
productManagers := middleware.RequireAuth([]byte(cfg.JWTSecret), "Admin", "superadmin", "Dono", "Gerente", "Seller")
|
||||||
|
|
||||||
// Companies (Empresas)
|
// Companies (Empresas)
|
||||||
|
|
@ -212,22 +218,20 @@ func (s *Server) Start(ctx context.Context) error {
|
||||||
// return err
|
// return err
|
||||||
// }
|
// }
|
||||||
|
|
||||||
// Seed Admin
|
// Seed platform superadmin automatically
|
||||||
if s.cfg.AdminEmail != "" && s.cfg.AdminPassword != "" {
|
{
|
||||||
// Checks if admin already exists
|
existingUser, err := repo.GetUserByEmail(ctx, bootstrapSuperadminEmail)
|
||||||
_, err := repo.GetUserByEmail(ctx, s.cfg.AdminEmail)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
// If not found, create
|
log.Printf("Seeding superadmin user: %s", bootstrapSuperadminEmail)
|
||||||
log.Printf("Seeding admin user: %s", s.cfg.AdminEmail)
|
|
||||||
|
|
||||||
// 1. Create/Get Admin Company
|
// 1. Create/Get platform company
|
||||||
adminCNPJ := "00000000000000"
|
adminCNPJ := "00000000000000"
|
||||||
company := &domain.Company{
|
company := &domain.Company{
|
||||||
ID: uuid.Nil,
|
ID: uuid.Nil,
|
||||||
CNPJ: adminCNPJ,
|
CNPJ: adminCNPJ,
|
||||||
CorporateName: "SaveInMed Admin",
|
CorporateName: "SaveInMed Platform",
|
||||||
Category: "admin",
|
Category: "platform",
|
||||||
LicenseNumber: "ADMIN",
|
LicenseNumber: "SUPERADMIN",
|
||||||
IsVerified: true,
|
IsVerified: true,
|
||||||
CreatedAt: time.Now().UTC(),
|
CreatedAt: time.Now().UTC(),
|
||||||
UpdatedAt: time.Now().UTC(),
|
UpdatedAt: time.Now().UTC(),
|
||||||
|
|
@ -257,26 +261,39 @@ func (s *Server) Start(ctx context.Context) error {
|
||||||
// In that case, CreateCompany will fail on CNPJ constraint.
|
// In that case, CreateCompany will fail on CNPJ constraint.
|
||||||
|
|
||||||
err := s.svc.RegisterAccount(ctx, company, &domain.User{
|
err := s.svc.RegisterAccount(ctx, company, &domain.User{
|
||||||
Role: "Admin",
|
Role: "superadmin",
|
||||||
Name: s.cfg.AdminName,
|
Name: bootstrapSuperadminName,
|
||||||
Username: s.cfg.AdminUsername,
|
Username: bootstrapSuperadminUsername,
|
||||||
Email: s.cfg.AdminEmail,
|
Email: bootstrapSuperadminEmail,
|
||||||
}, s.cfg.AdminPassword)
|
Superadmin: true,
|
||||||
|
}, bootstrapSuperadminPassword)
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
// If error is duplicate key on company, maybe we should fetch the company and try creating user only?
|
log.Printf("Failed to seed superadmin: %v", err)
|
||||||
// For now, let's log error but not fail startup hard
|
|
||||||
log.Printf("Failed to seed admin (may already exist): %v", err)
|
|
||||||
// Continue startup anyway
|
|
||||||
} else {
|
} else {
|
||||||
// FORCE VERIFY the admin company
|
// FORCE VERIFY the platform company
|
||||||
if _, err := s.svc.VerifyCompany(ctx, company.ID); err != nil {
|
if _, err := s.svc.VerifyCompany(ctx, company.ID); err != nil {
|
||||||
log.Printf("Failed to verify admin company: %v", err)
|
log.Printf("Failed to verify platform company: %v", err)
|
||||||
}
|
}
|
||||||
log.Printf("Admin user created successfully")
|
log.Printf("Superadmin user created successfully")
|
||||||
|
log.Printf("Bootstrap superadmin credentials: email=%s password=%s", bootstrapSuperadminEmail, bootstrapSuperadminPassword)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
log.Printf("Admin user %s already exists", s.cfg.AdminEmail)
|
existingUser.Role = "superadmin"
|
||||||
|
existingUser.Superadmin = true
|
||||||
|
if existingUser.Name == "" {
|
||||||
|
existingUser.Name = bootstrapSuperadminName
|
||||||
|
}
|
||||||
|
if existingUser.Username == "" {
|
||||||
|
existingUser.Username = bootstrapSuperadminUsername
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := s.svc.UpdateUser(ctx, existingUser, bootstrapSuperadminPassword); err != nil {
|
||||||
|
log.Printf("Failed to reconcile existing user %s as superadmin: %v", bootstrapSuperadminEmail, err)
|
||||||
|
} else {
|
||||||
|
log.Printf("Existing user %s reconciled as superadmin with bootstrap password", bootstrapSuperadminEmail)
|
||||||
|
log.Printf("Bootstrap superadmin credentials: email=%s password=%s", bootstrapSuperadminEmail, bootstrapSuperadminPassword)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -26,7 +26,7 @@ export async function POST(request: NextRequest) {
|
||||||
}
|
}
|
||||||
|
|
||||||
// URL base para redirecionamentos
|
// URL base para redirecionamentos
|
||||||
const baseUrl = process.env.NEXT_PUBLIC_APP_URL || 'https://saveinmed.com.br'
|
const baseUrl = process.env.NEXT_PUBLIC_APP_URL || 'https://dev.saveinmed.com.br'
|
||||||
|
|
||||||
console.log('Base URL:', baseUrl)
|
console.log('Base URL:', baseUrl)
|
||||||
console.log('Environment NEXT_PUBLIC_APP_URL:', process.env.NEXT_PUBLIC_APP_URL)
|
console.log('Environment NEXT_PUBLIC_APP_URL:', process.env.NEXT_PUBLIC_APP_URL)
|
||||||
|
|
|
||||||
|
|
@ -57,7 +57,7 @@ export async function POST(request: NextRequest) {
|
||||||
// Enviar Email para Admin
|
// Enviar Email para Admin
|
||||||
try {
|
try {
|
||||||
const adminEmail = process.env.ADMIN_EMAIL || 'admin@saveinmed.com.br';
|
const adminEmail = process.env.ADMIN_EMAIL || 'admin@saveinmed.com.br';
|
||||||
const frontendUrl = process.env.NEXT_PUBLIC_FRONTEND_URL || 'http://localhost:3000';
|
const frontendUrl = process.env.NEXT_PUBLIC_FRONTEND_URL || 'https://dev.saveinmed.com.br';
|
||||||
|
|
||||||
const adminEmailHtml = `
|
const adminEmailHtml = `
|
||||||
<div style="font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto;">
|
<div style="font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto;">
|
||||||
|
|
@ -91,7 +91,7 @@ export async function POST(request: NextRequest) {
|
||||||
|
|
||||||
// Enviar Email de Boas-vindas para o usuário
|
// Enviar Email de Boas-vindas para o usuário
|
||||||
try {
|
try {
|
||||||
const frontendUrl = process.env.NEXT_PUBLIC_FRONTEND_URL || 'http://localhost:3000';
|
const frontendUrl = process.env.NEXT_PUBLIC_FRONTEND_URL || 'https://dev.saveinmed.com.br';
|
||||||
|
|
||||||
const welcomeEmailHtml = `
|
const welcomeEmailHtml = `
|
||||||
<div style="font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto;">
|
<div style="font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto;">
|
||||||
|
|
@ -157,7 +157,7 @@ async function sendWhatsAppNotification(userData: UserData) {
|
||||||
const evolutionApiKey = process.env.EVOLUTION_API_KEY;
|
const evolutionApiKey = process.env.EVOLUTION_API_KEY;
|
||||||
const instanceName = process.env.EVOLUTION_INSTANCE_NAME;
|
const instanceName = process.env.EVOLUTION_INSTANCE_NAME;
|
||||||
const adminWhatsApp = process.env.ADMIN_WHATSAPP;
|
const adminWhatsApp = process.env.ADMIN_WHATSAPP;
|
||||||
const frontendUrl = process.env.NEXT_PUBLIC_FRONTEND_URL || 'http://localhost:3000';
|
const frontendUrl = process.env.NEXT_PUBLIC_FRONTEND_URL || 'https://dev.saveinmed.com.br';
|
||||||
|
|
||||||
if (!evolutionApiUrl || !evolutionApiKey || !instanceName || !adminWhatsApp) {
|
if (!evolutionApiUrl || !evolutionApiKey || !instanceName || !adminWhatsApp) {
|
||||||
throw new Error('Configurações do WhatsApp não encontradas');
|
throw new Error('Configurações do WhatsApp não encontradas');
|
||||||
|
|
@ -193,4 +193,4 @@ _Notificação automática do sistema SaveInMed_`;
|
||||||
const errorText = await response.text();
|
const errorText = await response.text();
|
||||||
throw new Error(`Erro ao enviar WhatsApp: ${response.status} - ${errorText}`);
|
throw new Error(`Erro ao enviar WhatsApp: ${response.status} - ${errorText}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -42,7 +42,7 @@ export async function POST(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
// Para modo teste do Resend, usar o email autorizado da conta
|
// Para modo teste do Resend, usar o email autorizado da conta
|
||||||
const adminEmail = process.env.RESEND_TEST_EMAIL || 'gladistone.cabobo20@gmail.com'; // Email autorizado no Resend
|
const adminEmail = process.env.RESEND_TEST_EMAIL || 'gladistone.cabobo20@gmail.com'; // Email autorizado no Resend
|
||||||
const frontendUrl = process.env.NEXT_PUBLIC_FRONTEND_URL || 'http://localhost:3000';
|
const frontendUrl = process.env.NEXT_PUBLIC_FRONTEND_URL || 'https://dev.saveinmed.com.br';
|
||||||
|
|
||||||
// Inicializar Resend apenas quando necessário
|
// Inicializar Resend apenas quando necessário
|
||||||
const resend = getResendClient();
|
const resend = getResendClient();
|
||||||
|
|
@ -167,7 +167,7 @@ async function sendWhatsAppNotification(userData: UserData) {
|
||||||
const evolutionApiKey = process.env.EVOLUTION_API_KEY;
|
const evolutionApiKey = process.env.EVOLUTION_API_KEY;
|
||||||
const instanceName = process.env.EVOLUTION_INSTANCE_NAME;
|
const instanceName = process.env.EVOLUTION_INSTANCE_NAME;
|
||||||
const adminWhatsApp = process.env.ADMIN_WHATSAPP;
|
const adminWhatsApp = process.env.ADMIN_WHATSAPP;
|
||||||
const frontendUrl = process.env.NEXT_PUBLIC_FRONTEND_URL || 'http://localhost:3000';
|
const frontendUrl = process.env.NEXT_PUBLIC_FRONTEND_URL || 'https://dev.saveinmed.com.br';
|
||||||
|
|
||||||
if (!evolutionApiUrl || !evolutionApiKey || !instanceName || !adminWhatsApp) {
|
if (!evolutionApiUrl || !evolutionApiKey || !instanceName || !adminWhatsApp) {
|
||||||
throw new Error('Configurações do WhatsApp não encontradas');
|
throw new Error('Configurações do WhatsApp não encontradas');
|
||||||
|
|
@ -203,4 +203,4 @@ _Notificação automática do sistema SaveInMed_`;
|
||||||
const errorText = await response.text();
|
const errorText = await response.text();
|
||||||
throw new Error(`Erro ao enviar WhatsApp: ${response.status} - ${errorText}`);
|
throw new Error(`Erro ao enviar WhatsApp: ${response.status} - ${errorText}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -31,7 +31,7 @@ export async function POST(request: NextRequest) {
|
||||||
// Simular envio de Email para Admin (log detalhado)
|
// Simular envio de Email para Admin (log detalhado)
|
||||||
try {
|
try {
|
||||||
const adminEmail = process.env.ADMIN_EMAIL || 'admin@saveinmed.com.br';
|
const adminEmail = process.env.ADMIN_EMAIL || 'admin@saveinmed.com.br';
|
||||||
const frontendUrl = process.env.NEXT_PUBLIC_FRONTEND_URL || 'http://localhost:3000';
|
const frontendUrl = process.env.NEXT_PUBLIC_FRONTEND_URL || 'https://dev.saveinmed.com.br';
|
||||||
|
|
||||||
console.log(`📧 [ADMIN EMAIL] Enviando para: ${adminEmail}`);
|
console.log(`📧 [ADMIN EMAIL] Enviando para: ${adminEmail}`);
|
||||||
console.log(`📧 [ADMIN EMAIL] Assunto: 🎉 `);
|
console.log(`📧 [ADMIN EMAIL] Assunto: 🎉 `);
|
||||||
|
|
@ -108,7 +108,7 @@ async function sendWhatsAppNotification(userData: UserData) {
|
||||||
const evolutionApiKey = process.env.EVOLUTION_API_KEY;
|
const evolutionApiKey = process.env.EVOLUTION_API_KEY;
|
||||||
const instanceName = process.env.EVOLUTION_INSTANCE_NAME;
|
const instanceName = process.env.EVOLUTION_INSTANCE_NAME;
|
||||||
const adminWhatsApp = process.env.ADMIN_WHATSAPP;
|
const adminWhatsApp = process.env.ADMIN_WHATSAPP;
|
||||||
const frontendUrl = process.env.NEXT_PUBLIC_FRONTEND_URL || 'http://localhost:3000';
|
const frontendUrl = process.env.NEXT_PUBLIC_FRONTEND_URL || 'https://dev.saveinmed.com.br';
|
||||||
|
|
||||||
if (!evolutionApiUrl || !evolutionApiKey || !instanceName || !adminWhatsApp) {
|
if (!evolutionApiUrl || !evolutionApiKey || !instanceName || !adminWhatsApp) {
|
||||||
throw new Error('Configurações do WhatsApp não encontradas');
|
throw new Error('Configurações do WhatsApp não encontradas');
|
||||||
|
|
@ -144,4 +144,4 @@ _Notificação automática do sistema SaveInMed_`;
|
||||||
const errorText = await response.text();
|
const errorText = await response.text();
|
||||||
throw new Error(`Erro ao enviar WhatsApp: ${response.status} - ${errorText}`);
|
throw new Error(`Erro ao enviar WhatsApp: ${response.status} - ${errorText}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,12 @@ import { useEmpresa } from "@/contexts/EmpresaContext";
|
||||||
import { translateError } from "@/lib/error-translator";
|
import { translateError } from "@/lib/error-translator";
|
||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
|
|
||||||
|
const BFF_BASE_URL =
|
||||||
|
process.env.NEXT_PUBLIC_BFF_API_URL ||
|
||||||
|
(process.env.NEXT_PUBLIC_API_URL
|
||||||
|
? `${process.env.NEXT_PUBLIC_API_URL}/api/v1`
|
||||||
|
: "https://api-dev.saveinmed.com.br/api/v1");
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Componente interno que usa useSearchParams
|
* Componente interno que usa useSearchParams
|
||||||
*/
|
*/
|
||||||
|
|
@ -54,7 +60,7 @@ const LoginPageContent = () => {
|
||||||
|
|
||||||
// Verificar autenticação usando BFF com o token no header Authorization
|
// Verificar autenticação usando BFF com o token no header Authorization
|
||||||
const response = await fetch(
|
const response = await fetch(
|
||||||
`${process.env.NEXT_PUBLIC_BFF_API_URL}/auth/me`,
|
`${BFF_BASE_URL}/auth/me`,
|
||||||
{
|
{
|
||||||
method: "GET",
|
method: "GET",
|
||||||
headers: {
|
headers: {
|
||||||
|
|
@ -92,7 +98,7 @@ const LoginPageContent = () => {
|
||||||
try {
|
try {
|
||||||
|
|
||||||
// 1. Fazer login no BFF
|
// 1. Fazer login no BFF
|
||||||
const baseUrl = process.env.NEXT_PUBLIC_BFF_API_URL!;
|
const baseUrl = BFF_BASE_URL;
|
||||||
const response = await fetch(`${baseUrl}/auth/login`, {
|
const response = await fetch(`${baseUrl}/auth/login`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
|
|
@ -228,7 +234,7 @@ const LoginPageContent = () => {
|
||||||
try {
|
try {
|
||||||
|
|
||||||
// 1. Fazer registro no BFF com dados corretos
|
// 1. Fazer registro no BFF com dados corretos
|
||||||
const baseUrl = process.env.NEXT_PUBLIC_BFF_API_URL!;
|
const baseUrl = BFF_BASE_URL;
|
||||||
const response = await fetch(`${baseUrl}/auth/register`, {
|
const response = await fetch(`${baseUrl}/auth/register`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
|
|
|
||||||
|
|
@ -42,7 +42,7 @@ export class EmailService {
|
||||||
* Envia notificação por email para administrador sobre novo cadastro
|
* Envia notificação por email para administrador sobre novo cadastro
|
||||||
*/
|
*/
|
||||||
async sendAdminNotification(userData: EmailData): Promise<void> {
|
async sendAdminNotification(userData: EmailData): Promise<void> {
|
||||||
const frontendUrl = process.env.NEXT_PUBLIC_FRONTEND_URL || 'http://localhost:3000';
|
const frontendUrl = process.env.NEXT_PUBLIC_FRONTEND_URL || 'https://dev.saveinmed.com.br';
|
||||||
const adminEmail = process.env.ADMIN_EMAIL || 'admin@saveinmed.com.br';
|
const adminEmail = process.env.ADMIN_EMAIL || 'admin@saveinmed.com.br';
|
||||||
|
|
||||||
const mailOptions = {
|
const mailOptions = {
|
||||||
|
|
@ -154,7 +154,7 @@ Notificação automática do sistema SaveInMed
|
||||||
* Envia email de boas-vindas para o usuário
|
* Envia email de boas-vindas para o usuário
|
||||||
*/
|
*/
|
||||||
async sendWelcomeEmail(userData: EmailData): Promise<void> {
|
async sendWelcomeEmail(userData: EmailData): Promise<void> {
|
||||||
const frontendUrl = process.env.NEXT_PUBLIC_FRONTEND_URL || 'http://localhost:3000';
|
const frontendUrl = process.env.NEXT_PUBLIC_FRONTEND_URL || 'https://dev.saveinmed.com.br';
|
||||||
|
|
||||||
const mailOptions = {
|
const mailOptions = {
|
||||||
from: `"SaveInMed - Boas Vindas" <${process.env.EMAIL_USER || 'nao-responda@saveinmed.com.br'}>`,
|
from: `"SaveInMed - Boas Vindas" <${process.env.EMAIL_USER || 'nao-responda@saveinmed.com.br'}>`,
|
||||||
|
|
@ -269,4 +269,4 @@ SaveInMed - Conectando farmácias, otimizando resultados
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export default EmailService;
|
export default EmailService;
|
||||||
|
|
|
||||||
48
saveinmed-frontend/.env.example
Normal file
48
saveinmed-frontend/.env.example
Normal file
|
|
@ -0,0 +1,48 @@
|
||||||
|
# Endpoint da API do Appwrite
|
||||||
|
NEXT_PUBLIC_APPWRITE_ENDPOINT=https://seu-endpoint.appwrite.io/v1
|
||||||
|
# ID do projeto Appwrite
|
||||||
|
NEXT_PUBLIC_APPWRITE_PROJECT_ID=seu_projeto_id
|
||||||
|
# ID do banco de dados do Appwrite
|
||||||
|
NEXT_PUBLIC_APPWRITE_DATABASE_ID=seu_banco_de_dados_id
|
||||||
|
# URL Frontend
|
||||||
|
NEXT_PUBLIC_FRONTEND_URL=https://dev.saveinmed.com.br
|
||||||
|
# API principal SaveInMed
|
||||||
|
NEXT_PUBLIC_API_URL=https://api-dev.saveinmed.com.br
|
||||||
|
# API BFF SaveInMed
|
||||||
|
NEXT_PUBLIC_BFF_API_URL=https://bff-dev.saveinmed.com.br/api/v1
|
||||||
|
NEXT_PUBLIC_BFF_API_URL_MP=https://bff-dev.saveinmed.com.br
|
||||||
|
# IDs das coleções
|
||||||
|
NEXT_PUBLIC_APPWRITE_COLLECTION_ENDERECOS_ID=enderecos
|
||||||
|
NEXT_PUBLIC_APPWRITE_COLLECTION_LABORATORIOS_ID=laboratorios
|
||||||
|
NEXT_PUBLIC_APPWRITE_COLLECTION_EMPRESAS_ID=empresas
|
||||||
|
NEXT_PUBLIC_APPWRITE_COLLECTION_USUARIOS_ID=usuarios
|
||||||
|
# Chave de API do Appwrite (server-side)
|
||||||
|
APPWRITE_API_KEY=sua_chave_de_api_secreta
|
||||||
|
# Chave de API do Appwrite (client-side)
|
||||||
|
NEXT_PUBLIC_APPWRITE_API_KEY=sua_chave_de_api_publica
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
# === Resend API (alternativa moderna para emails) ===
|
||||||
|
# Criar conta gratuita em resend.com e obter API key
|
||||||
|
RESEND_API_KEY=re_xxxxxxxxx_xxxxxxxxxxxxxxxxxx
|
||||||
|
# Email autorizado para testes (deve ser o mesmo da conta Resend)
|
||||||
|
RESEND_TEST_EMAIL=email@exemplo.com
|
||||||
|
|
||||||
|
# === WhatsApp Evolution API ===
|
||||||
|
# Configurações para notificações via WhatsApp
|
||||||
|
EVOLUTION_API_URL=https://sua-evolution-api.com
|
||||||
|
EVOLUTION_API_KEY=sua_api_key
|
||||||
|
EVOLUTION_INSTANCE_NAME=sua_instancia
|
||||||
|
ADMIN_WHATSAPP=5511999999999
|
||||||
|
|
||||||
|
# === Mercado Pago (sandbox/test) ===
|
||||||
|
# Atenção: durante desenvolvimento use as credenciais de SANDBOX (começam com "TEST-")
|
||||||
|
# Substitua os placeholders abaixo pelos valores do seu ambiente de testes do Mercado Pago.
|
||||||
|
MERCADO_PAGO_ACCESS_TOKEN=APP_USR-seu_token_de_acesso
|
||||||
|
MERCADO_PAGO_PUBLIC_KEY=APP_USR-seu_public_key
|
||||||
|
NEXT_PUBLIC_MERCADO_PAGO_PUBLIC_KEY=APP_USR-seu_public_key
|
||||||
|
# URL pública usada por callbacks e redirecionamentos
|
||||||
|
NEXT_PUBLIC_APP_URL=https://dev.saveinmed.com.br
|
||||||
|
# Em produção, use tokens do tipo APP_USR-... e mantenha esses segredos apenas no servidor (não no client)
|
||||||
|
|
||||||
372
saveinmed-frontend/src/app/checkout/page.tsx
Normal file
372
saveinmed-frontend/src/app/checkout/page.tsx
Normal file
|
|
@ -0,0 +1,372 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import React, { useEffect, useState } from "react";
|
||||||
|
import { useRouter, useSearchParams } from "next/navigation";
|
||||||
|
import { toast } from "react-hot-toast";
|
||||||
|
import Header from "@/components/Header";
|
||||||
|
import { useCarrinho } from "@/contexts/CarrinhoContext";
|
||||||
|
import { pedidoApiService } from "@/services/pedidoApiService";
|
||||||
|
import { useEmpresa } from "@/contexts/EmpresaContext";
|
||||||
|
import { CheckCircle, Truck, CreditCard, ChevronLeft, MapPin } from "lucide-react";
|
||||||
|
|
||||||
|
import PaymentBrick from "@/components/PaymentBrick";
|
||||||
|
|
||||||
|
export default function CheckoutPage() {
|
||||||
|
// ... (keep props)
|
||||||
|
const router = useRouter();
|
||||||
|
const searchParams = useSearchParams();
|
||||||
|
const pedidoId = searchParams.get("pedido");
|
||||||
|
const { itens, valorTotal, limparCarrinho } = useCarrinho();
|
||||||
|
const { empresa } = useEmpresa();
|
||||||
|
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [step, setStep] = useState(1);
|
||||||
|
|
||||||
|
const [selectedAddressId, setSelectedAddressId] = useState<string | number | null>(null);
|
||||||
|
const [addresses, setAddresses] = useState<any[]>([]);
|
||||||
|
const [userProfile, setUserProfile] = useState<any>(null);
|
||||||
|
|
||||||
|
const [shippingOptions, setShippingOptions] = useState<any[]>([]);
|
||||||
|
const [selectedShippingOption, setSelectedShippingOption] = useState<any>(null);
|
||||||
|
const [shippingFee, setShippingFee] = useState(0);
|
||||||
|
const [shippingLoading, setShippingLoading] = useState(false);
|
||||||
|
const [paymentError, setPaymentError] = useState<string>('');
|
||||||
|
const [debugInfo, setDebugInfo] = useState<any>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// ... (keep useEffects)
|
||||||
|
// Fetch user profile and addresses
|
||||||
|
const fetchData = async () => {
|
||||||
|
try {
|
||||||
|
const token = pedidoApiService.getAuthToken();
|
||||||
|
if (!token) return;
|
||||||
|
|
||||||
|
// Fetch User
|
||||||
|
const meRes = await fetch(`${process.env.NEXT_PUBLIC_API_URL || 'https://api-dev.saveinmed.com.br'}/api/v1/auth/me`, {
|
||||||
|
headers: { 'Authorization': `Bearer ${token}` }
|
||||||
|
});
|
||||||
|
if (meRes.ok) {
|
||||||
|
const userData = await meRes.json();
|
||||||
|
setUserProfile(userData);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch Addresses
|
||||||
|
const addrRes = await fetch(`${process.env.NEXT_PUBLIC_API_URL || 'https://api-dev.saveinmed.com.br'}/api/v1/enderecos`, {
|
||||||
|
headers: { 'Authorization': `Bearer ${token}` }
|
||||||
|
});
|
||||||
|
if (addrRes.ok) {
|
||||||
|
const addrData = await addrRes.json();
|
||||||
|
if (Array.isArray(addrData) && addrData.length > 0) {
|
||||||
|
setAddresses(addrData);
|
||||||
|
setSelectedAddressId(addrData[0].id);
|
||||||
|
} else if (!addrData || addrData.length === 0) {
|
||||||
|
const mock = {
|
||||||
|
id: 'mock-1',
|
||||||
|
logradouro: empresa?.endereco || "Rua Principal",
|
||||||
|
numero: empresa?.numero || "100",
|
||||||
|
bairro: empresa?.bairro || "Centro",
|
||||||
|
cidade: empresa?.cidade || "São Paulo",
|
||||||
|
uf: empresa?.estado || "SP",
|
||||||
|
cep: empresa?.cep || "01000-000",
|
||||||
|
titulo: "Endereço Padrão (Mock)"
|
||||||
|
};
|
||||||
|
setAddresses([mock]);
|
||||||
|
setSelectedAddressId('mock-1');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Error fetching checkout data", e);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
fetchData();
|
||||||
|
}, [empresa]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if ((!itens || itens.length === 0) && !pedidoId) {
|
||||||
|
toast.error("Seu carrinho está vazio");
|
||||||
|
router.push("/produtos");
|
||||||
|
}
|
||||||
|
}, [itens, router, pedidoId]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const calculateShipping = async () => {
|
||||||
|
setShippingOptions([]);
|
||||||
|
setSelectedShippingOption(null);
|
||||||
|
setShippingFee(0);
|
||||||
|
setDebugInfo(null);
|
||||||
|
|
||||||
|
if ((empresa?.id || userProfile?.company_id || (itens[0]?.produto as any)?.seller_id) && selectedAddressId) {
|
||||||
|
const addr = addresses.find(a => a.id === selectedAddressId);
|
||||||
|
if (!addr) return;
|
||||||
|
|
||||||
|
const buyerLat = addr.latitude || -23.5505;
|
||||||
|
const buyerLon = addr.longitude || -46.6333;
|
||||||
|
|
||||||
|
const vendorId = empresa?.id || itens[0]?.produto?.empresa_id;
|
||||||
|
|
||||||
|
if (!vendorId) return;
|
||||||
|
|
||||||
|
setShippingLoading(true);
|
||||||
|
|
||||||
|
const payload = {
|
||||||
|
vendor_id: vendorId,
|
||||||
|
buyer_latitude: buyerLat,
|
||||||
|
buyer_longitude: buyerLon,
|
||||||
|
cart_total_cents: Math.round(valorTotal * 100)
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${process.env.NEXT_PUBLIC_API_URL || 'https://api-dev.saveinmed.com.br'}/api/v1/shipping/calculate`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(payload)
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
const options = await response.json();
|
||||||
|
if (Array.isArray(options) && options.length > 0) {
|
||||||
|
setShippingOptions(options);
|
||||||
|
const defaultOption = options[0];
|
||||||
|
setSelectedShippingOption(defaultOption);
|
||||||
|
setShippingFee(defaultOption.value_cents || 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Shipping calc error", e);
|
||||||
|
} finally {
|
||||||
|
setShippingLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (valorTotal > 0 && selectedAddressId) {
|
||||||
|
calculateShipping();
|
||||||
|
}
|
||||||
|
}, [valorTotal, empresa, selectedAddressId, addresses, userProfile, itens]);
|
||||||
|
|
||||||
|
const handleShippingChange = (option: any) => {
|
||||||
|
setSelectedShippingOption(option);
|
||||||
|
setShippingFee(option.value_cents || 0);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleBrickPayment = async (formData: any) => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
if (!selectedAddressId) {
|
||||||
|
toast.error("Selecione um endereço de entrega");
|
||||||
|
setLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const addr = addresses.find(a => a.id === selectedAddressId);
|
||||||
|
const prod = itens[0]?.produto as any;
|
||||||
|
const vendorId = empresa?.id || prod?.seller_id || prod?.empresa_id || prod?.empresaId;
|
||||||
|
|
||||||
|
const payload = {
|
||||||
|
seller_id: vendorId,
|
||||||
|
items: itens.map(i => {
|
||||||
|
const p = i.produto as any;
|
||||||
|
return {
|
||||||
|
product_id: p.catalogo_id || p.product_id || p.id,
|
||||||
|
quantity: i.quantidade,
|
||||||
|
unit_cents: Math.round(((p.preco_final || p.price_cents / 100 || 0)) * 100)
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
shipping: {
|
||||||
|
recipient_name: userProfile?.name || empresa?.razao_social || "Cliente",
|
||||||
|
street: addr.logradouro || addr.street || "Rua Principal",
|
||||||
|
number: addr.numero || addr.number || "123",
|
||||||
|
district: addr.bairro || addr.district || "Centro",
|
||||||
|
city: addr.cidade || addr.city || "São Paulo",
|
||||||
|
state: addr.uf || addr.state || "SP",
|
||||||
|
zip_code: addr.cep || addr.zip_code || "00000000",
|
||||||
|
latitude: addr.latitude || -23.5505,
|
||||||
|
longitude: addr.longitude || -46.6333,
|
||||||
|
country: "BR"
|
||||||
|
},
|
||||||
|
payment_method: {
|
||||||
|
type: "credit_card", // Generic placeholder
|
||||||
|
installments: formData.installments || 1
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 1. Create Order
|
||||||
|
const response = await pedidoApiService.criar(payload);
|
||||||
|
|
||||||
|
if (response && response.success && response.data) {
|
||||||
|
const orderId = response.data.id || response.data.$id;
|
||||||
|
|
||||||
|
// 2. Process Payment via Bricks Token
|
||||||
|
const payRes = await pedidoApiService.processarPagamento(orderId, formData);
|
||||||
|
|
||||||
|
if (payRes.success) {
|
||||||
|
setStep(3);
|
||||||
|
limparCarrinho();
|
||||||
|
toast.success("Pagamento aprovado!");
|
||||||
|
} else {
|
||||||
|
// Payment Failed
|
||||||
|
const errorMsg = payRes.error || "Pagamento não aprovado. Tente outro cartão.";
|
||||||
|
setPaymentError(errorMsg);
|
||||||
|
toast.error(errorMsg);
|
||||||
|
}
|
||||||
|
|
||||||
|
} else {
|
||||||
|
toast.error(response.error || "Erro ao criar pedido");
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
toast.error("Erro ao processar pedido");
|
||||||
|
console.error(error);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (step === 3) {
|
||||||
|
// (Confirmation Screen - kept same)
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-gray-50 flex flex-col">
|
||||||
|
<Header user={userProfile} />
|
||||||
|
<main className="flex-1 max-w-7xl mx-auto p-4 w-full flex items-center justify-center">
|
||||||
|
<div className="bg-white rounded-lg shadow p-8 text-center max-w-lg w-full">
|
||||||
|
<div className="w-16 h-16 bg-green-100 rounded-full flex items-center justify-center mx-auto mb-4">
|
||||||
|
<CheckCircle className="w-8 h-8 text-green-600" />
|
||||||
|
</div>
|
||||||
|
<h2 className="text-2xl font-bold text-gray-900 mb-2">Pedido Confirmado!</h2>
|
||||||
|
<p className="text-gray-600 mb-6">
|
||||||
|
Seu pedido foi processado com sucesso.
|
||||||
|
</p>
|
||||||
|
<div className="space-y-3">
|
||||||
|
<button onClick={() => router.push("/meus-pedidos")} className="w-full bg-blue-600 text-white py-3 rounded-lg font-medium hover:bg-blue-700 transition">Ver Meus Pedidos</button>
|
||||||
|
<button onClick={() => router.push("/produtos")} className="w-full bg-white border border-gray-300 text-gray-700 py-3 rounded-lg font-medium hover:bg-gray-50 transition">Voltar para Loja</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-gray-50 pb-12">
|
||||||
|
<Header user={userProfile} />
|
||||||
|
|
||||||
|
<main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||||
|
<div className="mb-8">
|
||||||
|
<button onClick={() => router.back()} className="flex items-center text-sm text-gray-500 hover:text-gray-700 mb-4">
|
||||||
|
<ChevronLeft className="w-4 h-4 mr-1" /> Voltar
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-center">
|
||||||
|
<div className={`flex items-center ${step >= 1 ? "text-blue-600" : "text-gray-400"}`}>
|
||||||
|
<div className={`w-8 h-8 rounded-full flex items-center justify-center border-2 ${step >= 1 ? "border-blue-600 bg-blue-50" : "border-gray-300"} font-bold mr-2`}>1</div>
|
||||||
|
<span className="font-medium">Resumo & Entrega</span>
|
||||||
|
</div>
|
||||||
|
<div className={`w-12 h-0.5 mx-4 ${step >= 2 ? "bg-blue-600" : "bg-gray-300"}`}></div>
|
||||||
|
<div className={`flex items-center ${step >= 2 ? "text-blue-600" : "text-gray-400"}`}>
|
||||||
|
<div className={`w-8 h-8 rounded-full flex items-center justify-center border-2 ${step >= 2 ? "border-blue-600 bg-blue-50" : "border-gray-300"} font-bold mr-2`}>2</div>
|
||||||
|
<span className="font-medium">Pagamento</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
|
||||||
|
<div className="lg:col-span-2 space-y-6">
|
||||||
|
|
||||||
|
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-6">
|
||||||
|
<h3 className="text-lg font-semibold text-gray-900 mb-4 flex items-center gap-2">
|
||||||
|
<MapPin className="w-5 h-5 text-gray-500" /> Endereço de Entrega
|
||||||
|
</h3>
|
||||||
|
{/* Address List - Simplified for brevety in replace, but ideally keep logic */}
|
||||||
|
<div className="grid gap-4">
|
||||||
|
{addresses.map((addr) => (
|
||||||
|
<div
|
||||||
|
key={addr.id}
|
||||||
|
className={`border rounded-lg p-4 cursor-pointer relative transition-all ${selectedAddressId === addr.id ? 'border-blue-500 bg-blue-50 ring-1 ring-blue-500' : 'border-gray-200 hover:border-blue-300'}`}
|
||||||
|
onClick={() => setSelectedAddressId(addr.id)}
|
||||||
|
>
|
||||||
|
<p className="font-medium text-gray-900">{addr.titulo || userProfile?.name}</p>
|
||||||
|
<p className="text-sm text-gray-600">{addr.logradouro || addr.street}, {addr.numero || addr.number}</p>
|
||||||
|
{selectedAddressId === addr.id && <CheckCircle className="w-5 h-5 text-blue-600 absolute top-4 right-4" />}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{selectedAddressId && (
|
||||||
|
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-6">
|
||||||
|
<h3 className="text-lg font-semibold text-black mb-4 flex items-center gap-2">
|
||||||
|
<Truck className="w-5 h-5 text-gray-700" /> Método de Entrega
|
||||||
|
</h3>
|
||||||
|
{shippingLoading && <div className="text-gray-700">Calculando frete...</div>}
|
||||||
|
{!shippingLoading && shippingOptions.map((option, idx) => (
|
||||||
|
<label key={idx} className={`flex items-center p-4 border rounded-lg cursor-pointer mt-2 ${selectedShippingOption?.type === option.type ? 'border-blue-500 bg-blue-50' : 'border-gray-200 hover:border-gray-300'}`}>
|
||||||
|
<input type="radio" name="shipping" value={option.type} checked={selectedShippingOption?.type === option.type} onChange={() => handleShippingChange(option)} className="h-4 w-4 text-blue-600" />
|
||||||
|
<div className="ml-4 flex-1 flex justify-between">
|
||||||
|
<span className="text-black font-semibold">{option.description}</span>
|
||||||
|
<span className="text-black font-bold">{option.value_cents === 0 ? "Grátis" : `R$ ${(option.value_cents / 100).toFixed(2)}`}</span>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{step === 2 && (
|
||||||
|
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-6 animate-fade-in">
|
||||||
|
<h3 className="text-lg font-semibold text-black mb-4 flex items-center gap-2">
|
||||||
|
<CreditCard className="w-5 h-5 text-gray-700" /> Pagamento
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
{paymentError && (
|
||||||
|
<div className="mb-4 p-4 bg-red-50 border border-red-200 rounded-lg text-red-700 flex items-center gap-2">
|
||||||
|
<svg className="w-5 h-5 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"/></svg>
|
||||||
|
<span>{paymentError}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<PaymentBrick
|
||||||
|
amount={(valorTotal * 100 + shippingFee) / 100}
|
||||||
|
onPayment={handleBrickPayment}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="lg:col-span-1">
|
||||||
|
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-6 sticky top-6">
|
||||||
|
<h3 className="text-lg font-semibold text-black mb-4">Resumo do Pedido</h3>
|
||||||
|
<div className="space-y-4 mb-6 max-h-60 overflow-y-auto">
|
||||||
|
{itens.map((item) => (
|
||||||
|
<div key={item.produto.id} className="flex justify-between text-sm">
|
||||||
|
<span className="text-gray-700 flex-1 truncate mr-2">{item.quantidade}x {item.produto.nome}</span>
|
||||||
|
<span className="font-bold text-black">R$ {((item.produto.preco_final || 0) * item.quantidade).toFixed(2)}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="border-t border-gray-200 pt-4 space-y-2">
|
||||||
|
<div className="flex justify-between text-sm"><span className="text-gray-700 font-medium">Subtotal</span><span className="text-black font-bold">R$ {valorTotal.toFixed(2)}</span></div>
|
||||||
|
<div className="flex justify-between text-sm"><span className="text-gray-700 font-medium">Frete</span><span className="text-black font-bold">R$ {(shippingFee / 100).toFixed(2)}</span></div>
|
||||||
|
<div className="flex justify-between text-lg font-bold pt-2 border-t border-gray-100 mt-2">
|
||||||
|
<span className="text-black">Total</span><span className="text-blue-700">R$ {((valorTotal * 100 + shippingFee) / 100).toFixed(2)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-6">
|
||||||
|
{step === 1 ? (
|
||||||
|
<button onClick={() => setStep(2)} disabled={!selectedAddressId} className={`w-full text-white py-3 rounded-lg font-bold transition ${!selectedAddressId ? 'bg-gray-400' : 'bg-blue-600 hover:bg-blue-700'}`}>
|
||||||
|
Continuar para Pagamento
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<button onClick={() => setStep(1)} disabled={loading} className="w-full bg-white border border-gray-300 text-gray-700 py-2 rounded-lg font-medium hover:bg-gray-50 transition">
|
||||||
|
Voltar
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
95
saveinmed-frontend/src/services/enderecoApiService.ts
Normal file
95
saveinmed-frontend/src/services/enderecoApiService.ts
Normal file
|
|
@ -0,0 +1,95 @@
|
||||||
|
import axios from 'axios';
|
||||||
|
import { usuarioApiService } from './usuarioApiService';
|
||||||
|
|
||||||
|
const API_URL = process.env.NEXT_PUBLIC_BFF_API_URL || 'https://bff-dev.saveinmed.com.br/api/v1';
|
||||||
|
|
||||||
|
export interface EnderecoInput {
|
||||||
|
titulo: string;
|
||||||
|
cep: string;
|
||||||
|
logradouro: string;
|
||||||
|
numero: string;
|
||||||
|
complemento: string;
|
||||||
|
bairro: string;
|
||||||
|
cidade: string;
|
||||||
|
uf: string;
|
||||||
|
entity_id?: string; // Optional override for admins
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Endereco {
|
||||||
|
id: string;
|
||||||
|
entity_id: string;
|
||||||
|
titulo: string;
|
||||||
|
cep: string;
|
||||||
|
logradouro: string;
|
||||||
|
numero: string;
|
||||||
|
complemento: string;
|
||||||
|
bairro: string;
|
||||||
|
cidade: string;
|
||||||
|
uf: string;
|
||||||
|
latitude: number;
|
||||||
|
longitude: number;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const enderecoApiService = {
|
||||||
|
// Lists addresses. If userId provided and user is admin, implementation might vary,
|
||||||
|
// but currently list relies on logged in context.
|
||||||
|
// To list for another user, we might need to rely on the user object's 'enderecos' array
|
||||||
|
// or add a query param if backend supports it.
|
||||||
|
// For now, we use this for the logged-in actions or if we update the backend List to take params.
|
||||||
|
listar: async (entityId?: string): Promise<Endereco[]> => {
|
||||||
|
try {
|
||||||
|
const response = await axios.get(`${API_URL}/enderecos`, {
|
||||||
|
headers: {
|
||||||
|
'Authorization': `Bearer ${localStorage.getItem('access_token')}`
|
||||||
|
},
|
||||||
|
params: entityId ? { entity_id: entityId } : {}
|
||||||
|
});
|
||||||
|
return response.data;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Erro ao listar endereços', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
criar: async (dados: EnderecoInput): Promise<Endereco> => {
|
||||||
|
try {
|
||||||
|
const response = await axios.post(`${API_URL}/enderecos`, dados, {
|
||||||
|
headers: {
|
||||||
|
'Authorization': `Bearer ${localStorage.getItem('access_token')}`
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return response.data;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Erro ao criar endereço', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
atualizar: async (id: string, dados: EnderecoInput): Promise<void> => {
|
||||||
|
try {
|
||||||
|
await axios.put(`${API_URL}/enderecos/${id}`, dados, {
|
||||||
|
headers: {
|
||||||
|
'Authorization': `Bearer ${localStorage.getItem('access_token')}`
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Erro ao atualizar endereço', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
apagar: async (id: string): Promise<void> => {
|
||||||
|
try {
|
||||||
|
await axios.delete(`${API_URL}/enderecos/${id}`, {
|
||||||
|
headers: {
|
||||||
|
'Authorization': `Bearer ${localStorage.getItem('access_token')}`
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Erro ao apagar endereço', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
Loading…
Reference in a new issue