feat(multistore): agregar dripzone, sneaky, zapply y almeira-store listos para produccion en coolify y appwrite
5
.gitignore
vendored
@ -2,6 +2,7 @@
|
||||
|
||||
# dependencies
|
||||
/node_modules
|
||||
**/node_modules
|
||||
/.pnp
|
||||
.pnp.*
|
||||
.yarn/*
|
||||
@ -15,7 +16,9 @@
|
||||
|
||||
# next.js
|
||||
/.next/
|
||||
**/.next/
|
||||
/out/
|
||||
**/out/
|
||||
|
||||
# production
|
||||
/build
|
||||
@ -23,6 +26,8 @@
|
||||
# misc
|
||||
.DS_Store
|
||||
*.pem
|
||||
*.tar.gz
|
||||
*.log
|
||||
|
||||
# debug
|
||||
npm-debug.log*
|
||||
|
||||
41
almeira-store-app/.gitignore
vendored
Normal file
@ -0,0 +1,41 @@
|
||||
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||
|
||||
# dependencies
|
||||
/node_modules
|
||||
/.pnp
|
||||
.pnp.*
|
||||
.yarn/*
|
||||
!.yarn/patches
|
||||
!.yarn/plugins
|
||||
!.yarn/releases
|
||||
!.yarn/versions
|
||||
|
||||
# testing
|
||||
/coverage
|
||||
|
||||
# next.js
|
||||
/.next/
|
||||
/out/
|
||||
|
||||
# production
|
||||
/build
|
||||
|
||||
# misc
|
||||
.DS_Store
|
||||
*.pem
|
||||
|
||||
# debug
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
.pnpm-debug.log*
|
||||
|
||||
# env files (can opt-in for committing if needed)
|
||||
.env*
|
||||
|
||||
# vercel
|
||||
.vercel
|
||||
|
||||
# typescript
|
||||
*.tsbuildinfo
|
||||
next-env.d.ts
|
||||
9
almeira-store-app/AGENTS.md
Normal file
@ -0,0 +1,9 @@
|
||||
<!-- BEGIN:nextjs-agent-rules -->
|
||||
|
||||
# This is NOT the Next.js you know
|
||||
|
||||
This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` (resolved from this file's directory; in monorepos the `next` package may not be visible from the repo root) before writing any code. Heed deprecation notices.
|
||||
|
||||
This block is written and re-added by `next dev` — verify at `node_modules/next/dist/server/lib/generate-agent-files.js`. Removing it from a diff only re-creates the uncommitted change; committing it with your work keeps the tree clean.
|
||||
|
||||
<!-- END:nextjs-agent-rules -->
|
||||
1
almeira-store-app/CLAUDE.md
Normal file
@ -0,0 +1 @@
|
||||
@AGENTS.md
|
||||
54
almeira-store-app/Dockerfile
Normal file
@ -0,0 +1,54 @@
|
||||
FROM node:20-alpine AS base
|
||||
|
||||
# 1. Install dependencies
|
||||
FROM base AS deps
|
||||
RUN apk add --no-cache libc6-compat
|
||||
WORKDIR /app
|
||||
|
||||
COPY package.json package-lock.json* ./
|
||||
RUN npm ci
|
||||
|
||||
# 2. Build the app
|
||||
FROM base AS builder
|
||||
WORKDIR /app
|
||||
COPY --from=deps /app/node_modules ./node_modules
|
||||
COPY . .
|
||||
|
||||
ENV NEXT_TELEMETRY_DISABLED=1
|
||||
ENV NODE_ENV=production
|
||||
|
||||
# Appwrite and Public Envs for Build Phase
|
||||
ARG NEXT_PUBLIC_APPWRITE_ENDPOINT=https://appwrite.almeira.com.co/v1
|
||||
ARG NEXT_PUBLIC_APPWRITE_PROJECT_ID=69b84df5003bd8957c5c
|
||||
ARG NEXT_PUBLIC_APPWRITE_DATABASE_ID=sneakers_multistore_db
|
||||
ARG NEXT_PUBLIC_APPWRITE_MEDIA_BUCKET_ID=sneakers_media_bucket
|
||||
|
||||
ENV NEXT_PUBLIC_APPWRITE_ENDPOINT=$NEXT_PUBLIC_APPWRITE_ENDPOINT
|
||||
ENV NEXT_PUBLIC_APPWRITE_PROJECT_ID=$NEXT_PUBLIC_APPWRITE_PROJECT_ID
|
||||
ENV NEXT_PUBLIC_APPWRITE_DATABASE_ID=$NEXT_PUBLIC_APPWRITE_DATABASE_ID
|
||||
ENV NEXT_PUBLIC_APPWRITE_MEDIA_BUCKET_ID=$NEXT_PUBLIC_APPWRITE_MEDIA_BUCKET_ID
|
||||
|
||||
RUN npm run build
|
||||
|
||||
# 3. Production Runner
|
||||
FROM base AS runner
|
||||
WORKDIR /app
|
||||
|
||||
ENV NODE_ENV=production
|
||||
ENV NEXT_TELEMETRY_DISABLED=1
|
||||
ENV PORT=3000
|
||||
ENV HOSTNAME="0.0.0.0"
|
||||
|
||||
RUN addgroup --system --gid 1001 nodejs
|
||||
RUN adduser --system --uid 1001 nextjs
|
||||
|
||||
# Copy standalone output & public static files
|
||||
COPY --from=builder /app/public ./public
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
|
||||
|
||||
USER nextjs
|
||||
|
||||
EXPOSE 3000
|
||||
|
||||
CMD ["node", "server.js"]
|
||||
36
almeira-store-app/README.md
Normal file
@ -0,0 +1,36 @@
|
||||
This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app).
|
||||
|
||||
## Getting Started
|
||||
|
||||
First, run the development server:
|
||||
|
||||
```bash
|
||||
npm run dev
|
||||
# or
|
||||
yarn dev
|
||||
# or
|
||||
pnpm dev
|
||||
# or
|
||||
bun dev
|
||||
```
|
||||
|
||||
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
|
||||
|
||||
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
|
||||
|
||||
This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.
|
||||
|
||||
## Learn More
|
||||
|
||||
To learn more about Next.js, take a look at the following resources:
|
||||
|
||||
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
|
||||
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
|
||||
|
||||
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
|
||||
|
||||
## Deploy on Vercel
|
||||
|
||||
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
|
||||
|
||||
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.
|
||||
101
almeira-store-app/android/.gitignore
vendored
Normal file
@ -0,0 +1,101 @@
|
||||
# Using Android gitignore template: https://github.com/github/gitignore/blob/HEAD/Android.gitignore
|
||||
|
||||
# Built application files
|
||||
*.apk
|
||||
*.aar
|
||||
*.ap_
|
||||
*.aab
|
||||
|
||||
# Files for the ART/Dalvik VM
|
||||
*.dex
|
||||
|
||||
# Java class files
|
||||
*.class
|
||||
|
||||
# Generated files
|
||||
bin/
|
||||
gen/
|
||||
out/
|
||||
# Uncomment the following line in case you need and you don't have the release build type files in your app
|
||||
# release/
|
||||
|
||||
# Gradle files
|
||||
.gradle/
|
||||
build/
|
||||
|
||||
# Local configuration file (sdk path, etc)
|
||||
local.properties
|
||||
|
||||
# Proguard folder generated by Eclipse
|
||||
proguard/
|
||||
|
||||
# Log Files
|
||||
*.log
|
||||
|
||||
# Android Studio Navigation editor temp files
|
||||
.navigation/
|
||||
|
||||
# Android Studio captures folder
|
||||
captures/
|
||||
|
||||
# IntelliJ
|
||||
*.iml
|
||||
.idea/workspace.xml
|
||||
.idea/tasks.xml
|
||||
.idea/gradle.xml
|
||||
.idea/assetWizardSettings.xml
|
||||
.idea/dictionaries
|
||||
.idea/libraries
|
||||
# Android Studio 3 in .gitignore file.
|
||||
.idea/caches
|
||||
.idea/modules.xml
|
||||
# Comment next line if keeping position of elements in Navigation Editor is relevant for you
|
||||
.idea/navEditor.xml
|
||||
|
||||
# Keystore files
|
||||
# Uncomment the following lines if you do not want to check your keystore files in.
|
||||
#*.jks
|
||||
#*.keystore
|
||||
|
||||
# External native build folder generated in Android Studio 2.2 and later
|
||||
.externalNativeBuild
|
||||
.cxx/
|
||||
|
||||
# Google Services (e.g. APIs or Firebase)
|
||||
# google-services.json
|
||||
|
||||
# Freeline
|
||||
freeline.py
|
||||
freeline/
|
||||
freeline_project_description.json
|
||||
|
||||
# fastlane
|
||||
fastlane/report.xml
|
||||
fastlane/Preview.html
|
||||
fastlane/screenshots
|
||||
fastlane/test_output
|
||||
fastlane/readme.md
|
||||
|
||||
# Version control
|
||||
vcs.xml
|
||||
|
||||
# lint
|
||||
lint/intermediates/
|
||||
lint/generated/
|
||||
lint/outputs/
|
||||
lint/tmp/
|
||||
# lint/reports/
|
||||
|
||||
# Android Profiling
|
||||
*.hprof
|
||||
|
||||
# Cordova plugins for Capacitor
|
||||
capacitor-cordova-android-plugins
|
||||
|
||||
# Copied web assets
|
||||
app/src/main/assets/public
|
||||
|
||||
# Generated Config files
|
||||
app/src/main/assets/capacitor.config.json
|
||||
app/src/main/assets/capacitor.plugins.json
|
||||
app/src/main/res/xml/config.xml
|
||||
2
almeira-store-app/android/app/.gitignore
vendored
Normal file
@ -0,0 +1,2 @@
|
||||
/build/*
|
||||
!/build/.npmkeep
|
||||
54
almeira-store-app/android/app/build.gradle
Normal file
@ -0,0 +1,54 @@
|
||||
apply plugin: 'com.android.application'
|
||||
|
||||
android {
|
||||
namespace = "co.almeira.store"
|
||||
compileSdk = rootProject.ext.compileSdkVersion
|
||||
defaultConfig {
|
||||
applicationId "co.almeira.store"
|
||||
minSdkVersion rootProject.ext.minSdkVersion
|
||||
targetSdkVersion rootProject.ext.targetSdkVersion
|
||||
versionCode 1
|
||||
versionName "1.0"
|
||||
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
||||
aaptOptions {
|
||||
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
|
||||
// Default: https://android.googlesource.com/platform/frameworks/base/+/282e181b58cf72b6ca770dc7ca5f91f135444502/tools/aapt/AaptAssets.cpp#61
|
||||
ignoreAssetsPattern = '!.svn:!.git:!.ds_store:!*.scc:.*:!CVS:!thumbs.db:!picasa.ini:!*~'
|
||||
}
|
||||
}
|
||||
buildTypes {
|
||||
release {
|
||||
minifyEnabled false
|
||||
proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
repositories {
|
||||
flatDir{
|
||||
dirs '../capacitor-cordova-android-plugins/src/main/libs', 'libs'
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation fileTree(include: ['*.jar'], dir: 'libs')
|
||||
implementation "androidx.appcompat:appcompat:$androidxAppCompatVersion"
|
||||
implementation "androidx.coordinatorlayout:coordinatorlayout:$androidxCoordinatorLayoutVersion"
|
||||
implementation "androidx.core:core-splashscreen:$coreSplashScreenVersion"
|
||||
implementation project(':capacitor-android')
|
||||
testImplementation "junit:junit:$junitVersion"
|
||||
androidTestImplementation "androidx.test.ext:junit:$androidxJunitVersion"
|
||||
androidTestImplementation "androidx.test.espresso:espresso-core:$androidxEspressoCoreVersion"
|
||||
implementation project(':capacitor-cordova-android-plugins')
|
||||
}
|
||||
|
||||
apply from: 'capacitor.build.gradle'
|
||||
|
||||
try {
|
||||
def servicesJSON = file('google-services.json')
|
||||
if (servicesJSON.text) {
|
||||
apply plugin: 'com.google.gms.google-services'
|
||||
}
|
||||
} catch(Exception e) {
|
||||
logger.info("google-services.json not found, google-services plugin not applied. Push Notifications won't work")
|
||||
}
|
||||
19
almeira-store-app/android/app/capacitor.build.gradle
Normal file
@ -0,0 +1,19 @@
|
||||
// DO NOT EDIT THIS FILE! IT IS GENERATED EACH TIME "capacitor update" IS RUN
|
||||
|
||||
android {
|
||||
compileOptions {
|
||||
sourceCompatibility JavaVersion.VERSION_21
|
||||
targetCompatibility JavaVersion.VERSION_21
|
||||
}
|
||||
}
|
||||
|
||||
apply from: "../capacitor-cordova-android-plugins/cordova.variables.gradle"
|
||||
dependencies {
|
||||
|
||||
|
||||
}
|
||||
|
||||
|
||||
if (hasProperty('postBuildExtras')) {
|
||||
postBuildExtras()
|
||||
}
|
||||
21
almeira-store-app/android/app/proguard-rules.pro
vendored
Normal file
@ -0,0 +1,21 @@
|
||||
# Add project specific ProGuard rules here.
|
||||
# You can control the set of applied configuration files using the
|
||||
# proguardFiles setting in build.gradle.
|
||||
#
|
||||
# For more details, see
|
||||
# http://developer.android.com/guide/developing/tools/proguard.html
|
||||
|
||||
# If your project uses WebView with JS, uncomment the following
|
||||
# and specify the fully qualified class name to the JavaScript interface
|
||||
# class:
|
||||
#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
|
||||
# public *;
|
||||
#}
|
||||
|
||||
# Uncomment this to preserve the line number information for
|
||||
# debugging stack traces.
|
||||
#-keepattributes SourceFile,LineNumberTable
|
||||
|
||||
# If you keep the line number information, uncomment this to
|
||||
# hide the original source file name.
|
||||
#-renamesourcefileattribute SourceFile
|
||||
@ -0,0 +1,26 @@
|
||||
package com.getcapacitor.myapp;
|
||||
|
||||
import static org.junit.Assert.*;
|
||||
|
||||
import android.content.Context;
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4;
|
||||
import androidx.test.platform.app.InstrumentationRegistry;
|
||||
import org.junit.Test;
|
||||
import org.junit.runner.RunWith;
|
||||
|
||||
/**
|
||||
* Instrumented test, which will execute on an Android device.
|
||||
*
|
||||
* @see <a href="http://d.android.com/tools/testing">Testing documentation</a>
|
||||
*/
|
||||
@RunWith(AndroidJUnit4.class)
|
||||
public class ExampleInstrumentedTest {
|
||||
|
||||
@Test
|
||||
public void useAppContext() throws Exception {
|
||||
// Context of the app under test.
|
||||
Context appContext = InstrumentationRegistry.getInstrumentation().getTargetContext();
|
||||
|
||||
assertEquals("com.getcapacitor.app", appContext.getPackageName());
|
||||
}
|
||||
}
|
||||
41
almeira-store-app/android/app/src/main/AndroidManifest.xml
Normal file
@ -0,0 +1,41 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
|
||||
<application
|
||||
android:allowBackup="true"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:label="@string/app_name"
|
||||
android:roundIcon="@mipmap/ic_launcher_round"
|
||||
android:supportsRtl="true"
|
||||
android:theme="@style/AppTheme">
|
||||
|
||||
<activity
|
||||
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|locale|smallestScreenSize|screenLayout|uiMode|navigation|density"
|
||||
android:name=".MainActivity"
|
||||
android:label="@string/title_activity_main"
|
||||
android:theme="@style/AppTheme.NoActionBarLaunch"
|
||||
android:launchMode="singleTask"
|
||||
android:exported="true">
|
||||
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
|
||||
</activity>
|
||||
|
||||
<provider
|
||||
android:name="androidx.core.content.FileProvider"
|
||||
android:authorities="${applicationId}.fileprovider"
|
||||
android:exported="false"
|
||||
android:grantUriPermissions="true">
|
||||
<meta-data
|
||||
android:name="android.support.FILE_PROVIDER_PATHS"
|
||||
android:resource="@xml/file_paths"></meta-data>
|
||||
</provider>
|
||||
</application>
|
||||
|
||||
<!-- Permissions -->
|
||||
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
</manifest>
|
||||
@ -0,0 +1,5 @@
|
||||
package co.almeira.store;
|
||||
|
||||
import com.getcapacitor.BridgeActivity;
|
||||
|
||||
public class MainActivity extends BridgeActivity {}
|
||||
|
After Width: | Height: | Size: 7.5 KiB |
|
After Width: | Height: | Size: 3.9 KiB |
|
After Width: | Height: | Size: 9.0 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 7.7 KiB |
|
After Width: | Height: | Size: 4.0 KiB |
|
After Width: | Height: | Size: 9.6 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 17 KiB |
@ -0,0 +1,34 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:aapt="http://schemas.android.com/aapt"
|
||||
android:width="108dp"
|
||||
android:height="108dp"
|
||||
android:viewportHeight="108"
|
||||
android:viewportWidth="108">
|
||||
<path
|
||||
android:fillType="evenOdd"
|
||||
android:pathData="M32,64C32,64 38.39,52.99 44.13,50.95C51.37,48.37 70.14,49.57 70.14,49.57L108.26,87.69L108,109.01L75.97,107.97L32,64Z"
|
||||
android:strokeColor="#00000000"
|
||||
android:strokeWidth="1">
|
||||
<aapt:attr name="android:fillColor">
|
||||
<gradient
|
||||
android:endX="78.5885"
|
||||
android:endY="90.9159"
|
||||
android:startX="48.7653"
|
||||
android:startY="61.0927"
|
||||
android:type="linear">
|
||||
<item
|
||||
android:color="#44000000"
|
||||
android:offset="0.0" />
|
||||
<item
|
||||
android:color="#00000000"
|
||||
android:offset="1.0" />
|
||||
</gradient>
|
||||
</aapt:attr>
|
||||
</path>
|
||||
<path
|
||||
android:fillColor="#FFFFFF"
|
||||
android:fillType="nonZero"
|
||||
android:pathData="M66.94,46.02L66.94,46.02C72.44,50.07 76,56.61 76,64L32,64C32,56.61 35.56,50.11 40.98,46.06L36.18,41.19C35.45,40.45 35.45,39.3 36.18,38.56C36.91,37.81 38.05,37.81 38.78,38.56L44.25,44.05C47.18,42.57 50.48,41.71 54,41.71C57.48,41.71 60.78,42.57 63.68,44.05L69.11,38.56C69.84,37.81 70.98,37.81 71.71,38.56C72.44,39.3 72.44,40.45 71.71,41.19L66.94,46.02ZM62.94,56.92C64.08,56.92 65,56.01 65,54.88C65,53.76 64.08,52.85 62.94,52.85C61.8,52.85 60.88,53.76 60.88,54.88C60.88,56.01 61.8,56.92 62.94,56.92ZM45.06,56.92C46.2,56.92 47.13,56.01 47.13,54.88C47.13,53.76 46.2,52.85 45.06,52.85C43.92,52.85 43,53.76 43,54.88C43,56.01 43.92,56.92 45.06,56.92Z"
|
||||
android:strokeColor="#00000000"
|
||||
android:strokeWidth="1" />
|
||||
</vector>
|
||||
@ -0,0 +1,170 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="108dp"
|
||||
android:height="108dp"
|
||||
android:viewportHeight="108"
|
||||
android:viewportWidth="108">
|
||||
<path
|
||||
android:fillColor="#26A69A"
|
||||
android:pathData="M0,0h108v108h-108z" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M9,0L9,108"
|
||||
android:strokeColor="#33FFFFFF"
|
||||
android:strokeWidth="0.8" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M19,0L19,108"
|
||||
android:strokeColor="#33FFFFFF"
|
||||
android:strokeWidth="0.8" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M29,0L29,108"
|
||||
android:strokeColor="#33FFFFFF"
|
||||
android:strokeWidth="0.8" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M39,0L39,108"
|
||||
android:strokeColor="#33FFFFFF"
|
||||
android:strokeWidth="0.8" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M49,0L49,108"
|
||||
android:strokeColor="#33FFFFFF"
|
||||
android:strokeWidth="0.8" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M59,0L59,108"
|
||||
android:strokeColor="#33FFFFFF"
|
||||
android:strokeWidth="0.8" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M69,0L69,108"
|
||||
android:strokeColor="#33FFFFFF"
|
||||
android:strokeWidth="0.8" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M79,0L79,108"
|
||||
android:strokeColor="#33FFFFFF"
|
||||
android:strokeWidth="0.8" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M89,0L89,108"
|
||||
android:strokeColor="#33FFFFFF"
|
||||
android:strokeWidth="0.8" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M99,0L99,108"
|
||||
android:strokeColor="#33FFFFFF"
|
||||
android:strokeWidth="0.8" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,9L108,9"
|
||||
android:strokeColor="#33FFFFFF"
|
||||
android:strokeWidth="0.8" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,19L108,19"
|
||||
android:strokeColor="#33FFFFFF"
|
||||
android:strokeWidth="0.8" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,29L108,29"
|
||||
android:strokeColor="#33FFFFFF"
|
||||
android:strokeWidth="0.8" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,39L108,39"
|
||||
android:strokeColor="#33FFFFFF"
|
||||
android:strokeWidth="0.8" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,49L108,49"
|
||||
android:strokeColor="#33FFFFFF"
|
||||
android:strokeWidth="0.8" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,59L108,59"
|
||||
android:strokeColor="#33FFFFFF"
|
||||
android:strokeWidth="0.8" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,69L108,69"
|
||||
android:strokeColor="#33FFFFFF"
|
||||
android:strokeWidth="0.8" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,79L108,79"
|
||||
android:strokeColor="#33FFFFFF"
|
||||
android:strokeWidth="0.8" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,89L108,89"
|
||||
android:strokeColor="#33FFFFFF"
|
||||
android:strokeWidth="0.8" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,99L108,99"
|
||||
android:strokeColor="#33FFFFFF"
|
||||
android:strokeWidth="0.8" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M19,29L89,29"
|
||||
android:strokeColor="#33FFFFFF"
|
||||
android:strokeWidth="0.8" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M19,39L89,39"
|
||||
android:strokeColor="#33FFFFFF"
|
||||
android:strokeWidth="0.8" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M19,49L89,49"
|
||||
android:strokeColor="#33FFFFFF"
|
||||
android:strokeWidth="0.8" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M19,59L89,59"
|
||||
android:strokeColor="#33FFFFFF"
|
||||
android:strokeWidth="0.8" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M19,69L89,69"
|
||||
android:strokeColor="#33FFFFFF"
|
||||
android:strokeWidth="0.8" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M19,79L89,79"
|
||||
android:strokeColor="#33FFFFFF"
|
||||
android:strokeWidth="0.8" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M29,19L29,89"
|
||||
android:strokeColor="#33FFFFFF"
|
||||
android:strokeWidth="0.8" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M39,19L39,89"
|
||||
android:strokeColor="#33FFFFFF"
|
||||
android:strokeWidth="0.8" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M49,19L49,89"
|
||||
android:strokeColor="#33FFFFFF"
|
||||
android:strokeWidth="0.8" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M59,19L59,89"
|
||||
android:strokeColor="#33FFFFFF"
|
||||
android:strokeWidth="0.8" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M69,19L69,89"
|
||||
android:strokeColor="#33FFFFFF"
|
||||
android:strokeWidth="0.8" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M79,19L79,89"
|
||||
android:strokeColor="#33FFFFFF"
|
||||
android:strokeWidth="0.8" />
|
||||
</vector>
|
||||
BIN
almeira-store-app/android/app/src/main/res/drawable/splash.png
Normal file
|
After Width: | Height: | Size: 3.9 KiB |
@ -0,0 +1,12 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<androidx.coordinatorlayout.widget.CoordinatorLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
tools:context=".MainActivity">
|
||||
|
||||
<WebView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent" />
|
||||
</androidx.coordinatorlayout.widget.CoordinatorLayout>
|
||||
@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@color/ic_launcher_background"/>
|
||||
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
|
||||
</adaptive-icon>
|
||||
@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@color/ic_launcher_background"/>
|
||||
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
|
||||
</adaptive-icon>
|
||||
|
After Width: | Height: | Size: 2.7 KiB |
|
After Width: | Height: | Size: 3.4 KiB |
|
After Width: | Height: | Size: 4.2 KiB |
|
After Width: | Height: | Size: 1.8 KiB |
|
After Width: | Height: | Size: 2.1 KiB |
|
After Width: | Height: | Size: 2.7 KiB |
|
After Width: | Height: | Size: 3.9 KiB |
|
After Width: | Height: | Size: 4.9 KiB |
|
After Width: | Height: | Size: 6.4 KiB |
|
After Width: | Height: | Size: 6.5 KiB |
|
After Width: | Height: | Size: 9.6 KiB |
|
After Width: | Height: | Size: 10 KiB |
|
After Width: | Height: | Size: 9.2 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 16 KiB |
@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<color name="ic_launcher_background">#FFFFFF</color>
|
||||
</resources>
|
||||
@ -0,0 +1,7 @@
|
||||
<?xml version='1.0' encoding='utf-8'?>
|
||||
<resources>
|
||||
<string name="app_name">Almeira Store</string>
|
||||
<string name="title_activity_main">Almeira Store</string>
|
||||
<string name="package_name">co.almeira.store</string>
|
||||
<string name="custom_url_scheme">co.almeira.store</string>
|
||||
</resources>
|
||||
22
almeira-store-app/android/app/src/main/res/values/styles.xml
Normal file
@ -0,0 +1,22 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
|
||||
<!-- Base application theme. -->
|
||||
<style name="AppTheme" parent="Theme.AppCompat.Light.DarkActionBar">
|
||||
<!-- Customize your theme here. -->
|
||||
<item name="colorPrimary">@color/colorPrimary</item>
|
||||
<item name="colorPrimaryDark">@color/colorPrimaryDark</item>
|
||||
<item name="colorAccent">@color/colorAccent</item>
|
||||
</style>
|
||||
|
||||
<style name="AppTheme.NoActionBar" parent="Theme.AppCompat.DayNight.NoActionBar">
|
||||
<item name="windowActionBar">false</item>
|
||||
<item name="windowNoTitle">true</item>
|
||||
<item name="android:background">@null</item>
|
||||
</style>
|
||||
|
||||
|
||||
<style name="AppTheme.NoActionBarLaunch" parent="Theme.SplashScreen">
|
||||
<item name="android:background">@drawable/splash</item>
|
||||
</style>
|
||||
</resources>
|
||||
@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<paths xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<external-path name="my_images" path="." />
|
||||
<cache-path name="my_cache_images" path="." />
|
||||
</paths>
|
||||
@ -0,0 +1,18 @@
|
||||
package com.getcapacitor.myapp;
|
||||
|
||||
import static org.junit.Assert.*;
|
||||
|
||||
import org.junit.Test;
|
||||
|
||||
/**
|
||||
* Example local unit test, which will execute on the development machine (host).
|
||||
*
|
||||
* @see <a href="http://d.android.com/tools/testing">Testing documentation</a>
|
||||
*/
|
||||
public class ExampleUnitTest {
|
||||
|
||||
@Test
|
||||
public void addition_isCorrect() throws Exception {
|
||||
assertEquals(4, 2 + 2);
|
||||
}
|
||||
}
|
||||
29
almeira-store-app/android/build.gradle
Normal file
@ -0,0 +1,29 @@
|
||||
// Top-level build file where you can add configuration options common to all sub-projects/modules.
|
||||
|
||||
buildscript {
|
||||
|
||||
repositories {
|
||||
google()
|
||||
mavenCentral()
|
||||
}
|
||||
dependencies {
|
||||
classpath 'com.android.tools.build:gradle:8.13.0'
|
||||
classpath 'com.google.gms:google-services:4.4.4'
|
||||
|
||||
// NOTE: Do not place your application dependencies here; they belong
|
||||
// in the individual module build.gradle files
|
||||
}
|
||||
}
|
||||
|
||||
apply from: "variables.gradle"
|
||||
|
||||
allprojects {
|
||||
repositories {
|
||||
google()
|
||||
mavenCentral()
|
||||
}
|
||||
}
|
||||
|
||||
task clean(type: Delete) {
|
||||
delete rootProject.buildDir
|
||||
}
|
||||
3
almeira-store-app/android/capacitor.settings.gradle
Normal file
@ -0,0 +1,3 @@
|
||||
// DO NOT EDIT THIS FILE! IT IS GENERATED EACH TIME "capacitor update" IS RUN
|
||||
include ':capacitor-android'
|
||||
project(':capacitor-android').projectDir = new File('../node_modules/@capacitor/android/capacitor')
|
||||
22
almeira-store-app/android/gradle.properties
Normal file
@ -0,0 +1,22 @@
|
||||
# Project-wide Gradle settings.
|
||||
|
||||
# IDE (e.g. Android Studio) users:
|
||||
# Gradle settings configured through the IDE *will override*
|
||||
# any settings specified in this file.
|
||||
|
||||
# For more details on how to configure your build environment visit
|
||||
# http://www.gradle.org/docs/current/userguide/build_environment.html
|
||||
|
||||
# Specifies the JVM arguments used for the daemon process.
|
||||
# The setting is particularly useful for tweaking memory settings.
|
||||
org.gradle.jvmargs=-Xmx1536m
|
||||
|
||||
# When configured, Gradle will run in incubating parallel mode.
|
||||
# This option should only be used with decoupled projects. More details, visit
|
||||
# http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects
|
||||
# org.gradle.parallel=true
|
||||
|
||||
# AndroidX package structure to make it clearer which packages are bundled with the
|
||||
# Android operating system, and which are packaged with your app's APK
|
||||
# https://developer.android.com/topic/libraries/support-library/androidx-rn
|
||||
android.useAndroidX=true
|
||||
BIN
almeira-store-app/android/gradle/wrapper/gradle-wrapper.jar
vendored
Normal file
7
almeira-store-app/android/gradle/wrapper/gradle-wrapper.properties
vendored
Normal file
@ -0,0 +1,7 @@
|
||||
distributionBase=GRADLE_USER_HOME
|
||||
distributionPath=wrapper/dists
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.3-all.zip
|
||||
networkTimeout=10000
|
||||
validateDistributionUrl=true
|
||||
zipStoreBase=GRADLE_USER_HOME
|
||||
zipStorePath=wrapper/dists
|
||||
251
almeira-store-app/android/gradlew
vendored
Normal file
@ -0,0 +1,251 @@
|
||||
#!/bin/sh
|
||||
|
||||
#
|
||||
# Copyright © 2015-2021 the original authors.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# https://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
#
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
#
|
||||
|
||||
##############################################################################
|
||||
#
|
||||
# Gradle start up script for POSIX generated by Gradle.
|
||||
#
|
||||
# Important for running:
|
||||
#
|
||||
# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
|
||||
# noncompliant, but you have some other compliant shell such as ksh or
|
||||
# bash, then to run this script, type that shell name before the whole
|
||||
# command line, like:
|
||||
#
|
||||
# ksh Gradle
|
||||
#
|
||||
# Busybox and similar reduced shells will NOT work, because this script
|
||||
# requires all of these POSIX shell features:
|
||||
# * functions;
|
||||
# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
|
||||
# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
|
||||
# * compound commands having a testable exit status, especially «case»;
|
||||
# * various built-in commands including «command», «set», and «ulimit».
|
||||
#
|
||||
# Important for patching:
|
||||
#
|
||||
# (2) This script targets any POSIX shell, so it avoids extensions provided
|
||||
# by Bash, Ksh, etc; in particular arrays are avoided.
|
||||
#
|
||||
# The "traditional" practice of packing multiple parameters into a
|
||||
# space-separated string is a well documented source of bugs and security
|
||||
# problems, so this is (mostly) avoided, by progressively accumulating
|
||||
# options in "$@", and eventually passing that to Java.
|
||||
#
|
||||
# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
|
||||
# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
|
||||
# see the in-line comments for details.
|
||||
#
|
||||
# There are tweaks for specific operating systems such as AIX, CygWin,
|
||||
# Darwin, MinGW, and NonStop.
|
||||
#
|
||||
# (3) This script is generated from the Groovy template
|
||||
# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
|
||||
# within the Gradle project.
|
||||
#
|
||||
# You can find Gradle at https://github.com/gradle/gradle/.
|
||||
#
|
||||
##############################################################################
|
||||
|
||||
# Attempt to set APP_HOME
|
||||
|
||||
# Resolve links: $0 may be a link
|
||||
app_path=$0
|
||||
|
||||
# Need this for daisy-chained symlinks.
|
||||
while
|
||||
APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
|
||||
[ -h "$app_path" ]
|
||||
do
|
||||
ls=$( ls -ld "$app_path" )
|
||||
link=${ls#*' -> '}
|
||||
case $link in #(
|
||||
/*) app_path=$link ;; #(
|
||||
*) app_path=$APP_HOME$link ;;
|
||||
esac
|
||||
done
|
||||
|
||||
# This is normally unused
|
||||
# shellcheck disable=SC2034
|
||||
APP_BASE_NAME=${0##*/}
|
||||
# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
|
||||
APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit
|
||||
|
||||
# Use the maximum available, or set MAX_FD != -1 to use that value.
|
||||
MAX_FD=maximum
|
||||
|
||||
warn () {
|
||||
echo "$*"
|
||||
} >&2
|
||||
|
||||
die () {
|
||||
echo
|
||||
echo "$*"
|
||||
echo
|
||||
exit 1
|
||||
} >&2
|
||||
|
||||
# OS specific support (must be 'true' or 'false').
|
||||
cygwin=false
|
||||
msys=false
|
||||
darwin=false
|
||||
nonstop=false
|
||||
case "$( uname )" in #(
|
||||
CYGWIN* ) cygwin=true ;; #(
|
||||
Darwin* ) darwin=true ;; #(
|
||||
MSYS* | MINGW* ) msys=true ;; #(
|
||||
NONSTOP* ) nonstop=true ;;
|
||||
esac
|
||||
|
||||
CLASSPATH="\\\"\\\""
|
||||
|
||||
|
||||
# Determine the Java command to use to start the JVM.
|
||||
if [ -n "$JAVA_HOME" ] ; then
|
||||
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
|
||||
# IBM's JDK on AIX uses strange locations for the executables
|
||||
JAVACMD=$JAVA_HOME/jre/sh/java
|
||||
else
|
||||
JAVACMD=$JAVA_HOME/bin/java
|
||||
fi
|
||||
if [ ! -x "$JAVACMD" ] ; then
|
||||
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
|
||||
|
||||
Please set the JAVA_HOME variable in your environment to match the
|
||||
location of your Java installation."
|
||||
fi
|
||||
else
|
||||
JAVACMD=java
|
||||
if ! command -v java >/dev/null 2>&1
|
||||
then
|
||||
die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
|
||||
|
||||
Please set the JAVA_HOME variable in your environment to match the
|
||||
location of your Java installation."
|
||||
fi
|
||||
fi
|
||||
|
||||
# Increase the maximum file descriptors if we can.
|
||||
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
|
||||
case $MAX_FD in #(
|
||||
max*)
|
||||
# In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
|
||||
# shellcheck disable=SC2039,SC3045
|
||||
MAX_FD=$( ulimit -H -n ) ||
|
||||
warn "Could not query maximum file descriptor limit"
|
||||
esac
|
||||
case $MAX_FD in #(
|
||||
'' | soft) :;; #(
|
||||
*)
|
||||
# In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
|
||||
# shellcheck disable=SC2039,SC3045
|
||||
ulimit -n "$MAX_FD" ||
|
||||
warn "Could not set maximum file descriptor limit to $MAX_FD"
|
||||
esac
|
||||
fi
|
||||
|
||||
# Collect all arguments for the java command, stacking in reverse order:
|
||||
# * args from the command line
|
||||
# * the main class name
|
||||
# * -classpath
|
||||
# * -D...appname settings
|
||||
# * --module-path (only if needed)
|
||||
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
|
||||
|
||||
# For Cygwin or MSYS, switch paths to Windows format before running java
|
||||
if "$cygwin" || "$msys" ; then
|
||||
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
|
||||
CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" )
|
||||
|
||||
JAVACMD=$( cygpath --unix "$JAVACMD" )
|
||||
|
||||
# Now convert the arguments - kludge to limit ourselves to /bin/sh
|
||||
for arg do
|
||||
if
|
||||
case $arg in #(
|
||||
-*) false ;; # don't mess with options #(
|
||||
/?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
|
||||
[ -e "$t" ] ;; #(
|
||||
*) false ;;
|
||||
esac
|
||||
then
|
||||
arg=$( cygpath --path --ignore --mixed "$arg" )
|
||||
fi
|
||||
# Roll the args list around exactly as many times as the number of
|
||||
# args, so each arg winds up back in the position where it started, but
|
||||
# possibly modified.
|
||||
#
|
||||
# NB: a `for` loop captures its iteration list before it begins, so
|
||||
# changing the positional parameters here affects neither the number of
|
||||
# iterations, nor the values presented in `arg`.
|
||||
shift # remove old arg
|
||||
set -- "$@" "$arg" # push replacement arg
|
||||
done
|
||||
fi
|
||||
|
||||
|
||||
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
|
||||
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
|
||||
|
||||
# Collect all arguments for the java command:
|
||||
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
|
||||
# and any embedded shellness will be escaped.
|
||||
# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
|
||||
# treated as '${Hostname}' itself on the command line.
|
||||
|
||||
set -- \
|
||||
"-Dorg.gradle.appname=$APP_BASE_NAME" \
|
||||
-classpath "$CLASSPATH" \
|
||||
-jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \
|
||||
"$@"
|
||||
|
||||
# Stop when "xargs" is not available.
|
||||
if ! command -v xargs >/dev/null 2>&1
|
||||
then
|
||||
die "xargs is not available"
|
||||
fi
|
||||
|
||||
# Use "xargs" to parse quoted args.
|
||||
#
|
||||
# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
|
||||
#
|
||||
# In Bash we could simply go:
|
||||
#
|
||||
# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
|
||||
# set -- "${ARGS[@]}" "$@"
|
||||
#
|
||||
# but POSIX shell has neither arrays nor command substitution, so instead we
|
||||
# post-process each arg (as a line of input to sed) to backslash-escape any
|
||||
# character that might be a shell metacharacter, then use eval to reverse
|
||||
# that process (while maintaining the separation between arguments), and wrap
|
||||
# the whole thing up as a single "set" statement.
|
||||
#
|
||||
# This will of course break if any of these variables contains a newline or
|
||||
# an unmatched quote.
|
||||
#
|
||||
|
||||
eval "set -- $(
|
||||
printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
|
||||
xargs -n1 |
|
||||
sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
|
||||
tr '\n' ' '
|
||||
)" '"$@"'
|
||||
|
||||
exec "$JAVACMD" "$@"
|
||||
94
almeira-store-app/android/gradlew.bat
vendored
Normal file
@ -0,0 +1,94 @@
|
||||
@rem
|
||||
@rem Copyright 2015 the original author or authors.
|
||||
@rem
|
||||
@rem Licensed under the Apache License, Version 2.0 (the "License");
|
||||
@rem you may not use this file except in compliance with the License.
|
||||
@rem You may obtain a copy of the License at
|
||||
@rem
|
||||
@rem https://www.apache.org/licenses/LICENSE-2.0
|
||||
@rem
|
||||
@rem Unless required by applicable law or agreed to in writing, software
|
||||
@rem distributed under the License is distributed on an "AS IS" BASIS,
|
||||
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
@rem See the License for the specific language governing permissions and
|
||||
@rem limitations under the License.
|
||||
@rem
|
||||
@rem SPDX-License-Identifier: Apache-2.0
|
||||
@rem
|
||||
|
||||
@if "%DEBUG%"=="" @echo off
|
||||
@rem ##########################################################################
|
||||
@rem
|
||||
@rem Gradle startup script for Windows
|
||||
@rem
|
||||
@rem ##########################################################################
|
||||
|
||||
@rem Set local scope for the variables with windows NT shell
|
||||
if "%OS%"=="Windows_NT" setlocal
|
||||
|
||||
set DIRNAME=%~dp0
|
||||
if "%DIRNAME%"=="" set DIRNAME=.
|
||||
@rem This is normally unused
|
||||
set APP_BASE_NAME=%~n0
|
||||
set APP_HOME=%DIRNAME%
|
||||
|
||||
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
|
||||
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
|
||||
|
||||
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
|
||||
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
|
||||
|
||||
@rem Find java.exe
|
||||
if defined JAVA_HOME goto findJavaFromJavaHome
|
||||
|
||||
set JAVA_EXE=java.exe
|
||||
%JAVA_EXE% -version >NUL 2>&1
|
||||
if %ERRORLEVEL% equ 0 goto execute
|
||||
|
||||
echo. 1>&2
|
||||
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
|
||||
echo. 1>&2
|
||||
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
|
||||
echo location of your Java installation. 1>&2
|
||||
|
||||
goto fail
|
||||
|
||||
:findJavaFromJavaHome
|
||||
set JAVA_HOME=%JAVA_HOME:"=%
|
||||
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
|
||||
|
||||
if exist "%JAVA_EXE%" goto execute
|
||||
|
||||
echo. 1>&2
|
||||
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
|
||||
echo. 1>&2
|
||||
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
|
||||
echo location of your Java installation. 1>&2
|
||||
|
||||
goto fail
|
||||
|
||||
:execute
|
||||
@rem Setup the command line
|
||||
|
||||
set CLASSPATH=
|
||||
|
||||
|
||||
@rem Execute Gradle
|
||||
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %*
|
||||
|
||||
:end
|
||||
@rem End local scope for the variables with windows NT shell
|
||||
if %ERRORLEVEL% equ 0 goto mainEnd
|
||||
|
||||
:fail
|
||||
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
|
||||
rem the _cmd.exe /c_ return code!
|
||||
set EXIT_CODE=%ERRORLEVEL%
|
||||
if %EXIT_CODE% equ 0 set EXIT_CODE=1
|
||||
if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
|
||||
exit /b %EXIT_CODE%
|
||||
|
||||
:mainEnd
|
||||
if "%OS%"=="Windows_NT" endlocal
|
||||
|
||||
:omega
|
||||
5
almeira-store-app/android/settings.gradle
Normal file
@ -0,0 +1,5 @@
|
||||
include ':app'
|
||||
include ':capacitor-cordova-android-plugins'
|
||||
project(':capacitor-cordova-android-plugins').projectDir = new File('./capacitor-cordova-android-plugins/')
|
||||
|
||||
apply from: 'capacitor.settings.gradle'
|
||||
16
almeira-store-app/android/variables.gradle
Normal file
@ -0,0 +1,16 @@
|
||||
ext {
|
||||
minSdkVersion = 24
|
||||
compileSdkVersion = 36
|
||||
targetSdkVersion = 36
|
||||
androidxActivityVersion = '1.11.0'
|
||||
androidxAppCompatVersion = '1.7.1'
|
||||
androidxCoordinatorLayoutVersion = '1.3.0'
|
||||
androidxCoreVersion = '1.17.0'
|
||||
androidxFragmentVersion = '1.8.9'
|
||||
coreSplashScreenVersion = '1.2.0'
|
||||
androidxWebkitVersion = '1.14.0'
|
||||
junitVersion = '4.13.2'
|
||||
androidxJunitVersion = '1.3.0'
|
||||
androidxEspressoCoreVersion = '3.7.0'
|
||||
cordovaAndroidVersion = '14.0.1'
|
||||
}
|
||||
25
almeira-store-app/capacitor.config.ts
Normal file
@ -0,0 +1,25 @@
|
||||
import type { CapacitorConfig } from '@capacitor/cli';
|
||||
|
||||
const config: CapacitorConfig = {
|
||||
appId: 'co.almeira.store',
|
||||
appName: 'Almeira Store',
|
||||
webDir: 'public',
|
||||
server: {
|
||||
url: 'https://store.almeira.com.co',
|
||||
cleartext: true,
|
||||
androidScheme: 'https',
|
||||
},
|
||||
plugins: {
|
||||
SplashScreen: {
|
||||
launchShowDuration: 2000,
|
||||
backgroundColor: '#09090b',
|
||||
showSpinner: false,
|
||||
},
|
||||
StatusBar: {
|
||||
style: 'DARK',
|
||||
backgroundColor: '#09090b',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export default config;
|
||||
18
almeira-store-app/eslint.config.mjs
Normal file
@ -0,0 +1,18 @@
|
||||
import { defineConfig, globalIgnores } from "eslint/config";
|
||||
import nextVitals from "eslint-config-next/core-web-vitals";
|
||||
import nextTs from "eslint-config-next/typescript";
|
||||
|
||||
const eslintConfig = defineConfig([
|
||||
...nextVitals,
|
||||
...nextTs,
|
||||
// Override default ignores of eslint-config-next.
|
||||
globalIgnores([
|
||||
// Default ignores of eslint-config-next:
|
||||
".next/**",
|
||||
"out/**",
|
||||
"build/**",
|
||||
"next-env.d.ts",
|
||||
]),
|
||||
]);
|
||||
|
||||
export default eslintConfig;
|
||||
12
almeira-store-app/next.config.ts
Normal file
@ -0,0 +1,12 @@
|
||||
import type { NextConfig } from "next";
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
output: "standalone",
|
||||
images: {
|
||||
remotePatterns: [
|
||||
{ protocol: "https", hostname: "**" },
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
8446
almeira-store-app/package-lock.json
generated
Normal file
38
almeira-store-app/package.json
Normal file
@ -0,0 +1,38 @@
|
||||
{
|
||||
"name": "almeira-store-app",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev -H 0.0.0.0 -p 3001",
|
||||
"build": "next build",
|
||||
"start": "next start -H 0.0.0.0 -p 3001",
|
||||
"lint": "next lint"
|
||||
},
|
||||
"dependencies": {
|
||||
"@capacitor/android": "^8.5.0",
|
||||
"@capacitor/cli": "^8.5.0",
|
||||
"@capacitor/core": "^8.5.0",
|
||||
"@google/genai": "^2.19.0",
|
||||
"appwrite": "^26.2.0",
|
||||
"axios": "^1.18.0",
|
||||
"canvas-confetti": "^1.9.4",
|
||||
"clsx": "^2.1.1",
|
||||
"lucide-react": "^0.577.0",
|
||||
"next": "16.3.3",
|
||||
"node-appwrite": "^26.2.0",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"tailwind-merge": "^3.5.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/postcss": "^4",
|
||||
"@types/canvas-confetti": "^1.9.0",
|
||||
"@types/node": "^20",
|
||||
"@types/react": "^19",
|
||||
"@types/react-dom": "^19",
|
||||
"eslint": "^9",
|
||||
"eslint-config-next": "16.3.3",
|
||||
"tailwindcss": "^4",
|
||||
"typescript": "^5"
|
||||
}
|
||||
}
|
||||
7
almeira-store-app/postcss.config.mjs
Normal file
@ -0,0 +1,7 @@
|
||||
const config = {
|
||||
plugins: {
|
||||
"@tailwindcss/postcss": {},
|
||||
},
|
||||
};
|
||||
|
||||
export default config;
|
||||
1
almeira-store-app/public/file.svg
Normal file
@ -0,0 +1 @@
|
||||
<svg fill="none" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="M14.5 13.5V5.41a1 1 0 0 0-.3-.7L9.8.29A1 1 0 0 0 9.08 0H1.5v13.5A2.5 2.5 0 0 0 4 16h8a2.5 2.5 0 0 0 2.5-2.5m-1.5 0v-7H8v-5H3v12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1M9.5 5V2.12L12.38 5zM5.13 5h-.62v1.25h2.12V5zm-.62 3h7.12v1.25H4.5zm.62 3h-.62v1.25h7.12V11z" clip-rule="evenodd" fill="#666" fill-rule="evenodd"/></svg>
|
||||
|
After Width: | Height: | Size: 391 B |
1
almeira-store-app/public/globe.svg
Normal file
@ -0,0 +1 @@
|
||||
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><g clip-path="url(#a)"><path fill-rule="evenodd" clip-rule="evenodd" d="M10.27 14.1a6.5 6.5 0 0 0 3.67-3.45q-1.24.21-2.7.34-.31 1.83-.97 3.1M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16m.48-1.52a7 7 0 0 1-.96 0H7.5a4 4 0 0 1-.84-1.32q-.38-.89-.63-2.08a40 40 0 0 0 3.92 0q-.25 1.2-.63 2.08a4 4 0 0 1-.84 1.31zm2.94-4.76q1.66-.15 2.95-.43a7 7 0 0 0 0-2.58q-1.3-.27-2.95-.43a18 18 0 0 1 0 3.44m-1.27-3.54a17 17 0 0 1 0 3.64 39 39 0 0 1-4.3 0 17 17 0 0 1 0-3.64 39 39 0 0 1 4.3 0m1.1-1.17q1.45.13 2.69.34a6.5 6.5 0 0 0-3.67-3.44q.65 1.26.98 3.1M8.48 1.5l.01.02q.41.37.84 1.31.38.89.63 2.08a40 40 0 0 0-3.92 0q.25-1.2.63-2.08a4 4 0 0 1 .85-1.32 7 7 0 0 1 .96 0m-2.75.4a6.5 6.5 0 0 0-3.67 3.44 29 29 0 0 1 2.7-.34q.31-1.83.97-3.1M4.58 6.28q-1.66.16-2.95.43a7 7 0 0 0 0 2.58q1.3.27 2.95.43a18 18 0 0 1 0-3.44m.17 4.71q-1.45-.12-2.69-.34a6.5 6.5 0 0 0 3.67 3.44q-.65-1.27-.98-3.1" fill="#666"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h16v16H0z"/></clipPath></defs></svg>
|
||||
|
After Width: | Height: | Size: 1.0 KiB |
18
almeira-store-app/public/icon.svg
Normal file
@ -0,0 +1,18 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" width="512" height="512">
|
||||
<defs>
|
||||
<linearGradient id="almeiraGrad" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" stop-color="#06b6d4" />
|
||||
<stop offset="50%" stop-color="#3b82f6" />
|
||||
<stop offset="100%" stop-color="#ec4899" />
|
||||
</linearGradient>
|
||||
<linearGradient id="bgGrad" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" stop-color="#020617" />
|
||||
<stop offset="100%" stop-color="#0f172a" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<rect width="512" height="512" rx="128" fill="url(#bgGrad)" />
|
||||
<rect x="24" y="24" width="464" height="464" rx="104" fill="none" stroke="url(#almeiraGrad)" stroke-width="8" opacity="0.6" />
|
||||
<!-- Letter A Logo -->
|
||||
<text x="256" y="340" font-family="system-ui, -apple-system, sans-serif" font-weight="900" font-size="280" fill="url(#almeiraGrad)" text-anchor="middle">A</text>
|
||||
<circle cx="256" cy="410" r="16" fill="#06b6d4" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 982 B |
18
almeira-store-app/public/manifest.json
Normal file
@ -0,0 +1,18 @@
|
||||
{
|
||||
"name": "Almeira Store • Sneakers",
|
||||
"short_name": "Almeira Store",
|
||||
"description": "Tienda de calzado y zapatillas con entregas contraentrega en Cali y todo Colombia.",
|
||||
"start_url": "/",
|
||||
"display": "standalone",
|
||||
"background_color": "#020617",
|
||||
"theme_color": "#06b6d4",
|
||||
"orientation": "portrait",
|
||||
"icons": [
|
||||
{
|
||||
"src": "/icon.svg",
|
||||
"sizes": "any",
|
||||
"type": "image/svg+xml",
|
||||
"purpose": "any maskable"
|
||||
}
|
||||
]
|
||||
}
|
||||
1
almeira-store-app/public/next.svg
Normal file
@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 394 80"><path fill="#000" d="M262 0h68.5v12.7h-27.2v66.6h-13.6V12.7H262V0ZM149 0v12.7H94v20.4h44.3v12.6H94v21h55v12.6H80.5V0h68.7zm34.3 0h-17.8l63.8 79.4h17.9l-32-39.7 32-39.6h-17.9l-23 28.6-23-28.6zm18.3 56.7-9-11-27.1 33.7h17.8l18.3-22.7z"/><path fill="#000" d="M81 79.3 17 0H0v79.3h13.6V17l50.2 62.3H81Zm252.6-.4c-1 0-1.8-.4-2.5-1s-1.1-1.6-1.1-2.6.3-1.8 1-2.5 1.6-1 2.6-1 1.8.3 2.5 1a3.4 3.4 0 0 1 .6 4.3 3.7 3.7 0 0 1-3 1.8zm23.2-33.5h6v23.3c0 2.1-.4 4-1.3 5.5a9.1 9.1 0 0 1-3.8 3.5c-1.6.8-3.5 1.3-5.7 1.3-2 0-3.7-.4-5.3-1s-2.8-1.8-3.7-3.2c-.9-1.3-1.4-3-1.4-5h6c.1.8.3 1.6.7 2.2s1 1.2 1.6 1.5c.7.4 1.5.5 2.4.5 1 0 1.8-.2 2.4-.6a4 4 0 0 0 1.6-1.8c.3-.8.5-1.8.5-3V45.5zm30.9 9.1a4.4 4.4 0 0 0-2-3.3 7.5 7.5 0 0 0-4.3-1.1c-1.3 0-2.4.2-3.3.5-.9.4-1.6 1-2 1.6a3.5 3.5 0 0 0-.3 4c.3.5.7.9 1.3 1.2l1.8 1 2 .5 3.2.8c1.3.3 2.5.7 3.7 1.2a13 13 0 0 1 3.2 1.8 8.1 8.1 0 0 1 3 6.5c0 2-.5 3.7-1.5 5.1a10 10 0 0 1-4.4 3.5c-1.8.8-4.1 1.2-6.8 1.2-2.6 0-4.9-.4-6.8-1.2-2-.8-3.4-2-4.5-3.5a10 10 0 0 1-1.7-5.6h6a5 5 0 0 0 3.5 4.6c1 .4 2.2.6 3.4.6 1.3 0 2.5-.2 3.5-.6 1-.4 1.8-1 2.4-1.7a4 4 0 0 0 .8-2.4c0-.9-.2-1.6-.7-2.2a11 11 0 0 0-2.1-1.4l-3.2-1-3.8-1c-2.8-.7-5-1.7-6.6-3.2a7.2 7.2 0 0 1-2.4-5.7 8 8 0 0 1 1.7-5 10 10 0 0 1 4.3-3.5c2-.8 4-1.2 6.4-1.2 2.3 0 4.4.4 6.2 1.2 1.8.8 3.2 2 4.3 3.4 1 1.4 1.5 3 1.5 5h-5.8z"/></svg>
|
||||
|
After Width: | Height: | Size: 1.3 KiB |
1
almeira-store-app/public/vercel.svg
Normal file
@ -0,0 +1 @@
|
||||
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1155 1000"><path d="m577.3 0 577.4 1000H0z" fill="#fff"/></svg>
|
||||
|
After Width: | Height: | Size: 128 B |
1
almeira-store-app/public/window.svg
Normal file
@ -0,0 +1 @@
|
||||
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill-rule="evenodd" clip-rule="evenodd" d="M1.5 2.5h13v10a1 1 0 0 1-1 1h-11a1 1 0 0 1-1-1zM0 1h16v11.5a2.5 2.5 0 0 1-2.5 2.5h-11A2.5 2.5 0 0 1 0 12.5zm3.75 4.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5M7 4.75a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0m1.75.75a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5" fill="#666"/></svg>
|
||||
|
After Width: | Height: | Size: 385 B |
131
almeira-store-app/scripts/setup-appwrite.js
Normal file
@ -0,0 +1,131 @@
|
||||
/**
|
||||
* Script de inicialización de la Base de Datos Independiente en Appwrite
|
||||
* Ejecutar con: node scripts/setup-appwrite.js
|
||||
*/
|
||||
const { Client, Databases, Storage, Permission, Role } = require('node-appwrite');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
// Leer variables de .env.local manualmente si dotenv no está disponible en este script
|
||||
const envPath = path.resolve(__dirname, '../.env.local');
|
||||
if (fs.existsSync(envPath)) {
|
||||
const envContent = fs.readFileSync(envPath, 'utf8');
|
||||
envContent.split('\n').forEach((line) => {
|
||||
const trimmed = line.trim();
|
||||
if (trimmed && !trimmed.startsWith('#')) {
|
||||
const [k, ...v] = trimmed.split('=');
|
||||
if (k && v) process.env[k.trim()] = v.join('=').trim();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const endpoint = process.env.NEXT_PUBLIC_APPWRITE_ENDPOINT || 'https://appwrite.almeira.com.co/v1';
|
||||
const projectId = process.env.NEXT_PUBLIC_APPWRITE_PROJECT_ID || '69b84df5003bd8957c5c';
|
||||
const apiKey = process.env.APPWRITE_API_KEY;
|
||||
const databaseId = process.env.NEXT_PUBLIC_APPWRITE_DATABASE_ID || 'sneakers_multistore_db';
|
||||
const bucketId = process.env.NEXT_PUBLIC_APPWRITE_MEDIA_BUCKET_ID || 'sneakers_media_bucket';
|
||||
|
||||
if (!apiKey) {
|
||||
console.error('❌ Falta APPWRITE_API_KEY en .env.local');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const client = new Client()
|
||||
.setEndpoint(endpoint)
|
||||
.setProject(projectId)
|
||||
.setKey(apiKey);
|
||||
|
||||
const databases = new Databases(client);
|
||||
const storage = new Storage(client);
|
||||
|
||||
async function setup() {
|
||||
console.log('🚀 Iniciando configuración de la Base de Datos Independiente para la Tienda...');
|
||||
console.log(`Endpoint: ${endpoint}`);
|
||||
console.log(`Database ID: ${databaseId}`);
|
||||
console.log(`Bucket ID: ${bucketId}`);
|
||||
|
||||
// 1. Crear Base de Datos Independiente si no existe
|
||||
try {
|
||||
await databases.get(databaseId);
|
||||
console.log(`✅ Base de datos independiente "${databaseId}" ya existe.`);
|
||||
} catch (e) {
|
||||
console.log(`Creando base de datos independiente "${databaseId}"...`);
|
||||
await databases.create(databaseId, 'Sneakers Multi-Store DB');
|
||||
console.log(`✅ Base de datos "${databaseId}" creada con éxito.`);
|
||||
}
|
||||
|
||||
// 2. Crear Storage Bucket Independiente si no existe
|
||||
try {
|
||||
await storage.getBucket(bucketId);
|
||||
console.log(`✅ Storage Bucket "${bucketId}" ya existe.`);
|
||||
} catch (e) {
|
||||
console.log(`Creando Storage Bucket "${bucketId}"...`);
|
||||
try {
|
||||
await storage.createBucket(
|
||||
bucketId,
|
||||
'Sneakers Media Assets',
|
||||
[Permission.read(Role.any()), Permission.create(Role.any())],
|
||||
false, // fileSecurity
|
||||
true, // enabled
|
||||
50000000, // 50MB max file size
|
||||
['jpg', 'png', 'webp', 'jpeg']
|
||||
);
|
||||
console.log(`✅ Storage Bucket "${bucketId}" creado con éxito.`);
|
||||
} catch (bErr) {
|
||||
console.warn('Nota sobre creación de bucket:', bErr.message);
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Crear Colecciones Independientes
|
||||
const collections = [
|
||||
{
|
||||
id: 'products',
|
||||
name: 'Productos y Zapatillas',
|
||||
},
|
||||
{
|
||||
id: 'cities',
|
||||
name: 'Ciudades y Envíos',
|
||||
},
|
||||
{
|
||||
id: 'payment_methods',
|
||||
name: 'Métodos de Pago',
|
||||
},
|
||||
{
|
||||
id: 'orders',
|
||||
name: 'Pedidos de Clientes',
|
||||
},
|
||||
{
|
||||
id: 'supplier_posts',
|
||||
name: 'Feed de Mensajes WhatsApp',
|
||||
},
|
||||
];
|
||||
|
||||
for (const col of collections) {
|
||||
try {
|
||||
await databases.getCollection(databaseId, col.id);
|
||||
console.log(`✅ Colección "${col.name}" (${col.id}) ya existe.`);
|
||||
} catch (e) {
|
||||
console.log(`Creando colección "${col.name}" (${col.id})...`);
|
||||
try {
|
||||
await databases.createCollection(
|
||||
databaseId,
|
||||
col.id,
|
||||
col.name,
|
||||
[
|
||||
Permission.read(Role.any()),
|
||||
Permission.create(Role.any()),
|
||||
Permission.update(Role.any()),
|
||||
Permission.delete(Role.any()),
|
||||
]
|
||||
);
|
||||
console.log(`✅ Colección creada: ${col.id}`);
|
||||
} catch (cErr) {
|
||||
console.warn(`Error creando colección ${col.id}:`, cErr.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.log('🎉 ¡Base de datos y colecciones independientes configuradas al 100%!');
|
||||
}
|
||||
|
||||
setup().catch((err) => console.error('Error configurando Appwrite:', err));
|
||||
249
almeira-store-app/src/app/admin/ciudades/page.tsx
Normal file
@ -0,0 +1,249 @@
|
||||
'use client';
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { MapPin, Plus, Save, Trash2, Check, X, Truck, ShieldCheck } from 'lucide-react';
|
||||
import { City } from '@/types';
|
||||
import { INITIAL_CITIES } from '@/lib/data-service';
|
||||
import { formatCOP } from '@/lib/pricing-engine';
|
||||
|
||||
export default function AdminCitiesPage() {
|
||||
const [cities, setCities] = useState<City[]>(INITIAL_CITIES);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [message, setMessage] = useState<string | null>(null);
|
||||
|
||||
// New city form
|
||||
const [newName, setNewName] = useState('');
|
||||
const [newDepartment, setNewDepartment] = useState('');
|
||||
const [newShippingCost, setNewShippingCost] = useState('0');
|
||||
const [newDeliveryTime, setNewDeliveryTime] = useState('Entrega en 24 Horas');
|
||||
const [newAllowCod, setNewAllowCod] = useState(true);
|
||||
const [newFirstExchangeFree, setNewFirstExchangeFree] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
fetch('/api/cities')
|
||||
.then((r) => r.json())
|
||||
.then((d) => d.success && setCities(d.cities))
|
||||
.catch(() => {});
|
||||
}, []);
|
||||
|
||||
const handleToggleExchangeFree = async (city: City) => {
|
||||
const updated = { ...city, firstExchangeFree: !city.firstExchangeFree };
|
||||
setCities((prev) => prev.map((c) => (c.id === city.id ? updated : c)));
|
||||
|
||||
await fetch('/api/cities', {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ id: city.id, updates: { firstExchangeFree: updated.firstExchangeFree } }),
|
||||
});
|
||||
setMessage(`Política de 1er cambio gratis para "${city.name}" ${updated.firstExchangeFree ? 'ACTIVADA' : 'DESACTIVADA'}.`);
|
||||
};
|
||||
|
||||
const handleUpdateShipping = async (cityId: string, cost: number) => {
|
||||
setCities((prev) => prev.map((c) => (c.id === cityId ? { ...c, shippingCost: cost } : c)));
|
||||
await fetch('/api/cities', {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ id: cityId, updates: { shippingCost: cost } }),
|
||||
});
|
||||
setMessage('Tarifa de envío actualizada.');
|
||||
};
|
||||
|
||||
const handleAddCity = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!newName) return;
|
||||
|
||||
const payload = {
|
||||
name: newName,
|
||||
department: newDepartment || 'Colombia',
|
||||
shippingCost: parseInt(newShippingCost, 10) || 0,
|
||||
deliveryTime: newDeliveryTime,
|
||||
allowsContraentrega: newAllowCod,
|
||||
firstExchangeFree: newFirstExchangeFree,
|
||||
exchangePolicyNote: newFirstExchangeFree
|
||||
? `1er Cambio de Talla 100% GRATIS en ${newName} (posteriores con flete estándar)`
|
||||
: `Cambio de talla garantizado (flete logístico asumido por el cliente)`,
|
||||
};
|
||||
|
||||
const res = await fetch('/api/cities', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (data.success) {
|
||||
setCities((prev) => [...prev, data.city]);
|
||||
setNewName('');
|
||||
setNewDepartment('');
|
||||
setMessage(`Ciudad "${data.city.name}" agregada con éxito.`);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div>
|
||||
<h1 className="text-2xl font-black text-white flex items-center gap-2">
|
||||
<MapPin className="w-6 h-6 text-cyan-400" />
|
||||
<span>Configuración de Ciudades, Fletes y Garantía de Cambio</span>
|
||||
</h1>
|
||||
<p className="text-xs text-slate-400 mt-1">
|
||||
Administra las tarifas de envío, tiempos de entrega y configura si el <strong>1er cambio de talla es gratis</strong> o si el cliente asume el flete para cada ciudad.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{message && (
|
||||
<div className="bg-cyan-950/80 border border-cyan-500 text-cyan-300 px-4 py-3 rounded-2xl text-xs flex items-center justify-between">
|
||||
<span>{message}</span>
|
||||
<button onClick={() => setMessage(null)} className="font-bold text-white cursor-pointer">
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Add New City Card */}
|
||||
<form onSubmit={handleAddCity} className="bg-slate-900 border border-slate-800 rounded-3xl p-5 sm:p-6 space-y-4">
|
||||
<h3 className="font-bold text-white text-sm flex items-center gap-2">
|
||||
<Plus className="w-4 h-4 text-cyan-400" />
|
||||
<span>Registrar Nueva Ciudad o Municipio</span>
|
||||
</h3>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-3">
|
||||
<div>
|
||||
<label className="block text-[11px] font-bold text-slate-400 mb-1">Nombre de la Ciudad *</label>
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
placeholder="Ej. Buga, Tuluá, Armenia"
|
||||
value={newName}
|
||||
onChange={(e) => setNewName(e.target.value)}
|
||||
className="w-full bg-slate-950 border border-slate-800 rounded-xl px-3 py-2 text-xs text-white outline-hidden"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-[11px] font-bold text-slate-400 mb-1">Departamento</label>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Ej. Valle del Cauca"
|
||||
value={newDepartment}
|
||||
onChange={(e) => setNewDepartment(e.target.value)}
|
||||
className="w-full bg-slate-950 border border-slate-800 rounded-xl px-3 py-2 text-xs text-white outline-hidden"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-[11px] font-bold text-slate-400 mb-1">Costo de Envío (COP)</label>
|
||||
<input
|
||||
type="number"
|
||||
placeholder="0 para Gratis"
|
||||
value={newShippingCost}
|
||||
onChange={(e) => setNewShippingCost(e.target.value)}
|
||||
className="w-full bg-slate-950 border border-slate-800 rounded-xl px-3 py-2 text-xs text-white outline-hidden"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-[11px] font-bold text-slate-400 mb-1">Tiempo de Entrega</label>
|
||||
<input
|
||||
type="text"
|
||||
value={newDeliveryTime}
|
||||
onChange={(e) => setNewDeliveryTime(e.target.value)}
|
||||
className="w-full bg-slate-950 border border-slate-800 rounded-xl px-3 py-2 text-xs text-white outline-hidden"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center justify-between gap-3 pt-2 border-t border-slate-800">
|
||||
<div className="flex items-center space-x-4">
|
||||
<label className="flex items-center space-x-2 text-xs text-slate-300 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={newAllowCod}
|
||||
onChange={(e) => setNewAllowCod(e.target.checked)}
|
||||
className="rounded text-cyan-500 focus:ring-0"
|
||||
/>
|
||||
<span>Permitir Pago Contraentrega</span>
|
||||
</label>
|
||||
|
||||
<label className="flex items-center space-x-2 text-xs text-emerald-400 font-bold cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={newFirstExchangeFree}
|
||||
onChange={(e) => setNewFirstExchangeFree(e.target.checked)}
|
||||
className="rounded text-emerald-500 focus:ring-0"
|
||||
/>
|
||||
<span>1er Cambio de Talla 100% GRATIS</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
className="bg-cyan-600 hover:bg-cyan-700 text-white font-bold text-xs px-4 py-2 rounded-xl flex items-center space-x-1.5 transition-all cursor-pointer shadow-sm"
|
||||
>
|
||||
<Plus className="w-3.5 h-3.5" />
|
||||
<span>Agregar Ciudad</span>
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{/* Cities List */}
|
||||
<div className="bg-slate-900 border border-slate-800 rounded-3xl p-5 sm:p-6 space-y-3">
|
||||
<h3 className="font-bold text-white text-sm">Ciudades y Zonas Registradas ({cities.length})</h3>
|
||||
|
||||
<div className="divide-y divide-slate-800">
|
||||
{cities.map((city) => (
|
||||
<div key={city.id} className="py-3.5 flex flex-col md:flex-row md:items-center justify-between gap-3">
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center space-x-2">
|
||||
<span className="font-bold text-white text-sm">{city.name}</span>
|
||||
<span className="text-[11px] text-slate-400">({city.department})</span>
|
||||
{city.allowsContraentrega ? (
|
||||
<span className="text-[10px] bg-emerald-500/20 text-emerald-300 font-bold px-2 py-0.5 rounded border border-emerald-500/30">
|
||||
Contraentrega Activo
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-[10px] bg-amber-500/20 text-amber-300 font-bold px-2 py-0.5 rounded border border-amber-500/30">
|
||||
Solo Despacho Nacional
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-slate-400 flex items-center gap-1.5">
|
||||
<Truck className="w-3.5 h-3.5 text-cyan-400" />
|
||||
<span>{city.deliveryTime}</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Actions & Policy Toggle */}
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
{/* 1st Exchange Free Toggle Button */}
|
||||
<button
|
||||
onClick={() => handleToggleExchangeFree(city)}
|
||||
className={`text-xs font-bold px-3 py-1.5 rounded-xl border flex items-center gap-1.5 transition-all cursor-pointer ${
|
||||
city.firstExchangeFree
|
||||
? 'bg-emerald-500/20 border-emerald-500/30 text-emerald-300 hover:bg-emerald-500/30'
|
||||
: 'bg-slate-950 border-slate-700 text-slate-400 hover:text-white'
|
||||
}`}
|
||||
>
|
||||
<ShieldCheck className="w-3.5 h-3.5" />
|
||||
<span>{city.firstExchangeFree ? '1er Cambio GRATIS' : 'Flete por Cliente'}</span>
|
||||
</button>
|
||||
|
||||
{/* Shipping cost edit */}
|
||||
<div className="flex items-center space-x-1.5 bg-slate-950 border border-slate-800 px-3 py-1.5 rounded-xl">
|
||||
<span className="text-[11px] text-slate-400">Flete:</span>
|
||||
<input
|
||||
type="number"
|
||||
value={city.shippingCost}
|
||||
onChange={(e) => handleUpdateShipping(city.id, parseInt(e.target.value, 10) || 0)}
|
||||
className="w-20 bg-transparent text-xs font-bold text-white outline-hidden text-right"
|
||||
/>
|
||||
<span className="text-[11px] text-slate-500">COP</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
352
almeira-store-app/src/app/admin/configuracion/page.tsx
Normal file
@ -0,0 +1,352 @@
|
||||
'use client';
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Store, Mail, Phone, MessageCircle, Instagram, MapPin, CreditCard, Save, CheckCircle2, DollarSign, Percent, ShieldCheck, QrCode, ExternalLink, Zap } from 'lucide-react';
|
||||
import { StoreSettings } from '@/types';
|
||||
|
||||
export default function AdminSettingsPage() {
|
||||
const [settings, setSettings] = useState<StoreSettings>({
|
||||
storeName: 'Almeira Store',
|
||||
slogan: 'Sneakers & Moda Urbana',
|
||||
email: 'carlos.andres.lasso@gmail.com',
|
||||
phone: '3167455877',
|
||||
whatsappNumber: '573167455877',
|
||||
instagramHandle: '@almeirastore.co',
|
||||
address: 'Bodega Central / Cali',
|
||||
city: 'Cali, Colombia',
|
||||
nequiAccount: '316 745 5877',
|
||||
daviplataAccount: '316 745 5877',
|
||||
accountHolder: 'Carlos Andrés Lasso',
|
||||
boldPaymentLink: 'https://bold.co/p/carlos-lasso',
|
||||
boldApiKey: '',
|
||||
boldMerchantId: '',
|
||||
boldQrUrl: '',
|
||||
boldEnabled: true,
|
||||
defaultProfitMargin: 35,
|
||||
defaultDepositAmount: 10000,
|
||||
});
|
||||
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [toast, setToast] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
fetch('/api/settings')
|
||||
.then((r) => r.json())
|
||||
.then((d) => d.success && setSettings(d.settings))
|
||||
.catch(() => {});
|
||||
}, []);
|
||||
|
||||
const handleChange = (field: keyof StoreSettings, value: any) => {
|
||||
setSettings((prev) => ({ ...prev, [field]: value }));
|
||||
};
|
||||
|
||||
const handlePhoneChange = (val: string) => {
|
||||
const cleanDigits = val.replace(/\D/g, '');
|
||||
const cleanWa = cleanDigits.startsWith('57') ? cleanDigits : `57${cleanDigits}`;
|
||||
setSettings((prev) => ({
|
||||
...prev,
|
||||
phone: cleanDigits,
|
||||
whatsappNumber: cleanWa,
|
||||
}));
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setIsSaving(true);
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/settings', {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(settings),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (data.success) {
|
||||
setToast('✅ ¡Datos de la tienda, WhatsApp oficial y pasarela Bold.co actualizados exitosamente!');
|
||||
setTimeout(() => setToast(null), 4500);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6 max-w-4xl">
|
||||
{/* Header */}
|
||||
<div>
|
||||
<h1 className="text-2xl font-black text-white flex items-center gap-2">
|
||||
<Store className="w-6 h-6 text-cyan-400" />
|
||||
<span>Información de la Tienda & Medios de Pago</span>
|
||||
</h1>
|
||||
<p className="text-xs text-slate-400 mt-1">
|
||||
Configura los datos oficiales del negocio: WhatsApp oficial de atención, correo electrónico, pasarela <strong>Bold.co</strong> (Link de pago, QR y botón), Nequi, Daviplata y reglas de margen.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{toast && (
|
||||
<div className="bg-emerald-950/80 border border-emerald-500 text-emerald-300 px-4 py-3 rounded-2xl text-xs flex items-center justify-between shadow-lg animate-in fade-in">
|
||||
<span>{toast}</span>
|
||||
<button onClick={() => setToast(null)} className="font-bold text-white cursor-pointer ml-3">
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
{/* Section 1: Identidad & WhatsApp Oficial */}
|
||||
<div className="bg-slate-900 border border-slate-800 rounded-3xl p-5 sm:p-6 space-y-4 shadow-xl">
|
||||
<h2 className="text-sm font-bold text-white flex items-center gap-2 border-b border-slate-800 pb-3">
|
||||
<Mail className="w-4 h-4 text-cyan-400" />
|
||||
<span>Identidad & WhatsApp Oficial de Atención</span>
|
||||
</h2>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4 text-xs">
|
||||
<div>
|
||||
<label className="block text-[11px] font-bold text-slate-400 mb-1">Nombre Comercial de la Tienda *</label>
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
value={settings.storeName}
|
||||
onChange={(e) => handleChange('storeName', e.target.value)}
|
||||
className="w-full bg-slate-950 border border-slate-800 rounded-xl px-3.5 py-2.5 text-white font-semibold outline-hidden focus:border-cyan-400"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-[11px] font-bold text-slate-400 mb-1">Slogan / Frase Promocional</label>
|
||||
<input
|
||||
type="text"
|
||||
value={settings.slogan || ''}
|
||||
onChange={(e) => handleChange('slogan', e.target.value)}
|
||||
className="w-full bg-slate-950 border border-slate-800 rounded-xl px-3.5 py-2.5 text-white outline-hidden focus:border-cyan-400"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-[11px] font-bold text-slate-400 mb-1">Correo Electrónico Oficial *</label>
|
||||
<input
|
||||
type="email"
|
||||
required
|
||||
value={settings.email}
|
||||
onChange={(e) => handleChange('email', e.target.value)}
|
||||
className="w-full bg-slate-950 border border-slate-800 rounded-xl px-3.5 py-2.5 text-white outline-hidden focus:border-cyan-400 font-mono"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-[11px] font-bold text-emerald-400 mb-1">
|
||||
WhatsApp Oficial de Ventas (Celular) *
|
||||
</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
type="tel"
|
||||
required
|
||||
placeholder="ej: 3167455877"
|
||||
value={settings.phone}
|
||||
onChange={(e) => handlePhoneChange(e.target.value)}
|
||||
className="w-full bg-slate-950 border border-slate-800 rounded-xl pl-9 pr-3.5 py-2.5 text-emerald-300 font-bold outline-hidden focus:border-emerald-400"
|
||||
/>
|
||||
<MessageCircle className="w-4 h-4 text-emerald-400 absolute left-3 top-1/2 -translate-y-1/2" />
|
||||
</div>
|
||||
<p className="text-[10px] text-slate-400 mt-1">
|
||||
Link generado: <a href={`https://wa.me/${settings.whatsappNumber}`} target="_blank" className="text-emerald-400 underline font-mono">https://wa.me/{settings.whatsappNumber}</a>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-[11px] font-bold text-slate-400 mb-1">Instagram (@usuario)</label>
|
||||
<input
|
||||
type="text"
|
||||
value={settings.instagramHandle || ''}
|
||||
onChange={(e) => handleChange('instagramHandle', e.target.value)}
|
||||
className="w-full bg-slate-950 border border-slate-800 rounded-xl px-3.5 py-2.5 text-white outline-hidden focus:border-cyan-400"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-[11px] font-bold text-slate-400 mb-1">Ciudad Principal / Bodega</label>
|
||||
<input
|
||||
type="text"
|
||||
value={settings.city}
|
||||
onChange={(e) => handleChange('city', e.target.value)}
|
||||
className="w-full bg-slate-950 border border-slate-800 rounded-xl px-3.5 py-2.5 text-white outline-hidden focus:border-cyan-400"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Section 2: Pasarela Bold.co (Link de Pago, QR o Botón) */}
|
||||
<div className="bg-gradient-to-br from-slate-900 to-indigo-950/40 border border-indigo-500/30 rounded-3xl p-5 sm:p-6 space-y-4 shadow-xl">
|
||||
<div className="flex items-center justify-between border-b border-indigo-500/20 pb-3">
|
||||
<h2 className="text-sm font-bold text-white flex items-center gap-2">
|
||||
<Zap className="w-4 h-4 text-indigo-400" />
|
||||
<span>Integración con Bold.co (Link de Pago, Botón & QR)</span>
|
||||
</h2>
|
||||
<label className="flex items-center space-x-2 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={settings.boldEnabled ?? true}
|
||||
onChange={(e) => handleChange('boldEnabled', e.target.checked)}
|
||||
className="w-4 h-4 rounded text-indigo-500 focus:ring-indigo-400"
|
||||
/>
|
||||
<span className="text-xs font-bold text-indigo-300">Activo en Checkout</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4 text-xs">
|
||||
<div className="sm:col-span-2">
|
||||
<label className="block text-[11px] font-bold text-indigo-300 mb-1">
|
||||
Link de Pago Oficial Bold.co (Link personalizado o de tu negocio) *
|
||||
</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
type="url"
|
||||
placeholder="https://bold.co/p/carlos-lasso"
|
||||
value={settings.boldPaymentLink || ''}
|
||||
onChange={(e) => handleChange('boldPaymentLink', e.target.value)}
|
||||
className="w-full bg-slate-950 border border-indigo-500/40 rounded-xl pl-9 pr-3.5 py-2.5 text-white font-mono outline-hidden focus:border-indigo-400"
|
||||
/>
|
||||
<ExternalLink className="w-4 h-4 text-indigo-400 absolute left-3 top-1/2 -translate-y-1/2" />
|
||||
</div>
|
||||
<p className="text-[10px] text-slate-400 mt-1">
|
||||
Tus clientes podrán pagar por PSE, Tarjeta de Crédito/Débito o Nequi directamente con este enlace oficial de Bold.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-[11px] font-bold text-slate-400 mb-1">
|
||||
Enlace / URL de Código QR Bold (Opcional)
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="ej: https://.../qr-bold.png"
|
||||
value={settings.boldQrUrl || ''}
|
||||
onChange={(e) => handleChange('boldQrUrl', e.target.value)}
|
||||
className="w-full bg-slate-950 border border-slate-800 rounded-xl px-3.5 py-2.5 text-white outline-hidden focus:border-indigo-400"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-[11px] font-bold text-slate-400 mb-1">
|
||||
Bold API Key / Merchant ID (Opcional para Botón Integrado)
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
placeholder="pk_bold_..."
|
||||
value={settings.boldApiKey || ''}
|
||||
onChange={(e) => handleChange('boldApiKey', e.target.value)}
|
||||
className="w-full bg-slate-950 border border-slate-800 rounded-xl px-3.5 py-2.5 text-white font-mono outline-hidden focus:border-indigo-400"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Section 3: Cuentas de Recaudo Nequi / Daviplata */}
|
||||
<div className="bg-slate-900 border border-slate-800 rounded-3xl p-5 sm:p-6 space-y-4 shadow-xl">
|
||||
<h2 className="text-sm font-bold text-white flex items-center gap-2 border-b border-slate-800 pb-3">
|
||||
<CreditCard className="w-4 h-4 text-cyan-400" />
|
||||
<span>Cuentas para Recaudo de Anticipos ($10.000 COP)</span>
|
||||
</h2>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4 text-xs">
|
||||
<div>
|
||||
<label className="block text-[11px] font-bold text-slate-400 mb-1">Nombre del Titular *</label>
|
||||
<input
|
||||
type="text"
|
||||
value={settings.accountHolder || ''}
|
||||
onChange={(e) => handleChange('accountHolder', e.target.value)}
|
||||
className="w-full bg-slate-950 border border-slate-800 rounded-xl px-3.5 py-2.5 text-white outline-hidden focus:border-cyan-400 font-bold"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-[11px] font-bold text-slate-400 mb-1">Número Cuenta Nequi</label>
|
||||
<input
|
||||
type="text"
|
||||
value={settings.nequiAccount || ''}
|
||||
onChange={(e) => handleChange('nequiAccount', e.target.value)}
|
||||
className="w-full bg-slate-950 border border-slate-800 rounded-xl px-3.5 py-2.5 text-white outline-hidden focus:border-cyan-400"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-[11px] font-bold text-slate-400 mb-1">Número Cuenta Daviplata</label>
|
||||
<input
|
||||
type="text"
|
||||
value={settings.daviplataAccount || ''}
|
||||
onChange={(e) => handleChange('daviplataAccount', e.target.value)}
|
||||
className="w-full bg-slate-950 border border-slate-800 rounded-xl px-3.5 py-2.5 text-white outline-hidden focus:border-cyan-400"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Section 4: Reglas de Precios y Anticipo */}
|
||||
<div className="bg-slate-900 border border-slate-800 rounded-3xl p-5 sm:p-6 space-y-4 shadow-xl">
|
||||
<h2 className="text-sm font-bold text-white flex items-center gap-2 border-b border-slate-800 pb-3">
|
||||
<Percent className="w-4 h-4 text-cyan-400" />
|
||||
<span>Reglas Comerciales de Ganancia & Redondeo</span>
|
||||
</h2>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4 text-xs">
|
||||
<div>
|
||||
<label className="block text-[11px] font-bold text-slate-400 mb-1">
|
||||
% Mínimo de Ganancia Automático
|
||||
</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
type="number"
|
||||
min="1"
|
||||
max="100"
|
||||
value={settings.defaultProfitMargin}
|
||||
onChange={(e) => handleChange('defaultProfitMargin', Number(e.target.value))}
|
||||
className="w-full bg-slate-950 border border-slate-800 rounded-xl pl-9 pr-3.5 py-2.5 text-white font-bold outline-hidden focus:border-cyan-400"
|
||||
/>
|
||||
<Percent className="w-4 h-4 text-cyan-400 absolute left-3 top-1/2 -translate-y-1/2" />
|
||||
</div>
|
||||
<p className="text-[10px] text-slate-400 mt-1">
|
||||
El sistema calcula el precio y lo <strong>redondea al siguiente múltiplo de $5.000 COP</strong> (ej: $132.000 ➔ $135.000).
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-[11px] font-bold text-slate-400 mb-1">
|
||||
Monto Fijo de Separación ($ COP)
|
||||
</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
type="number"
|
||||
step="1000"
|
||||
value={settings.defaultDepositAmount}
|
||||
onChange={(e) => handleChange('defaultDepositAmount', Number(e.target.value))}
|
||||
className="w-full bg-slate-950 border border-slate-800 rounded-xl pl-9 pr-3.5 py-2.5 text-white font-bold outline-hidden focus:border-cyan-400"
|
||||
/>
|
||||
<DollarSign className="w-4 h-4 text-cyan-400 absolute left-3 top-1/2 -translate-y-1/2" />
|
||||
</div>
|
||||
<p className="text-[10px] text-slate-400 mt-1">
|
||||
Valor solicitado al comprador para apartar el calzado con el proveedor.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Submit Bar */}
|
||||
<div className="flex items-center justify-between pt-2">
|
||||
<p className="text-[11px] text-slate-400">
|
||||
Todos los cambios se reflejan inmediatamente en la tienda pública, el carrito y los mensajes de WhatsApp.
|
||||
</p>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isSaving}
|
||||
className="bg-gradient-to-r from-cyan-500 to-blue-600 hover:from-cyan-400 hover:to-blue-500 text-white font-bold text-xs px-6 py-3 rounded-2xl flex items-center space-x-2 shadow-lg cursor-pointer transition-all active:scale-95 disabled:opacity-50"
|
||||
>
|
||||
<Save className="w-4 h-4" />
|
||||
<span>{isSaving ? 'Guardando cambios...' : 'Guardar Configuración'}</span>
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
365
almeira-store-app/src/app/admin/feed-whatsapp/page.tsx
Normal file
@ -0,0 +1,365 @@
|
||||
'use client';
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import { Radio, Sparkles, Share2, AlertTriangle, ShieldCheck, Plus, Send, Layers } from 'lucide-react';
|
||||
import { SupplierPost, Product } from '@/types';
|
||||
import { INITIAL_SUPPLIER_POSTS, INITIAL_PRODUCTS } from '@/lib/data-service';
|
||||
import { checkProductDuplicate } from '@/lib/deduplication-engine';
|
||||
import { formatCOP } from '@/lib/pricing-engine';
|
||||
import { BRANDS, BrandId } from '@/config/brands';
|
||||
|
||||
export default function SupplierFeedPage() {
|
||||
const [posts, setPosts] = useState<SupplierPost[]>(INITIAL_SUPPLIER_POSTS);
|
||||
const [products, setProducts] = useState<Product[]>(INITIAL_PRODUCTS);
|
||||
const [isProcessing, setIsProcessing] = useState(false);
|
||||
const [actionMessage, setActionMessage] = useState<string | null>(null);
|
||||
|
||||
// Target brand selector for publishing
|
||||
const [targetBrand, setTargetBrand] = useState<BrandId | 'all'>('almeira');
|
||||
|
||||
// Simulación de nuevo mensaje entrante de grupo para probar en vivo
|
||||
const [testText, setTestText] = useState(
|
||||
'🔥 LLEGANDO A BODEGA HOY 🔥\nNike Air Max 90 White Black\nTallas: 38 a 43\nMayorista: $90.000 c/u\nEnvío inmediato contraentrega'
|
||||
);
|
||||
const [testGroup, setTestGroup] = useState('Canal Mayorista A');
|
||||
|
||||
const handleSimulateIncoming = async () => {
|
||||
setIsProcessing(true);
|
||||
try {
|
||||
const res = await fetch('/api/webhook/whatsapp', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
text: testText,
|
||||
groupInfo: { subject: testGroup },
|
||||
}),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (data.success && data.post) {
|
||||
setPosts((prev) => [data.post, ...prev]);
|
||||
setActionMessage('✅ Nuevo mensaje de WhatsApp procesado con IA.');
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
} finally {
|
||||
setIsProcessing(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handlePublishFacebook = async (post: SupplierPost) => {
|
||||
if (!post.extractedProduct) return;
|
||||
setIsProcessing(true);
|
||||
try {
|
||||
const brandName = targetBrand === 'all' ? 'Todas las Tiendas' : BRANDS[targetBrand].name;
|
||||
const res = await fetch('/api/publish/facebook', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
productName: post.extractedProduct.name,
|
||||
brand: post.extractedProduct.brand,
|
||||
retailPrice: post.extractedProduct.suggestedRetailPrice,
|
||||
sizes: post.extractedProduct.sizes,
|
||||
images: post.rawImages,
|
||||
}),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (data.success) {
|
||||
setActionMessage(`🚀 ¡Publicado en Facebook bajo la marca "${brandName}"! (ID: ${data.postId})`);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
} finally {
|
||||
setIsProcessing(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAddToStore = async (post: SupplierPost) => {
|
||||
if (!post.extractedProduct) return;
|
||||
const brandLabel = targetBrand === 'all' ? 'Multi-Marca' : BRANDS[targetBrand].shortName;
|
||||
const newProduct: Product = {
|
||||
id: `snk-${Date.now()}`,
|
||||
name: post.extractedProduct.name,
|
||||
brand: post.extractedProduct.brand,
|
||||
supplierPrice: post.extractedProduct.supplierPrice,
|
||||
retailPrice: post.extractedProduct.suggestedRetailPrice,
|
||||
originalPrice: Math.round(post.extractedProduct.suggestedRetailPrice * 1.35),
|
||||
sizes: post.extractedProduct.sizes,
|
||||
images: post.rawImages,
|
||||
description: `Zapatillas calidad premium garantizada para entrega inmediata.`,
|
||||
category: post.extractedProduct.category,
|
||||
inStock: true,
|
||||
isActive: true,
|
||||
originBadge: `Bodega Central`,
|
||||
supplierName: post.supplierName || post.channelName || 'DIFUSION JOVIC STORE',
|
||||
supplierPhone: post.supplierPhone || '573134848203',
|
||||
};
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/products', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(newProduct),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (data.success) {
|
||||
setProducts((prev) => [data.product, ...prev]);
|
||||
setActionMessage(`✨ "${newProduct.name}" habilitado en el catálogo para ${brandLabel}.`);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
|
||||
<div>
|
||||
<h1 className="text-2xl font-black text-white flex items-center gap-2">
|
||||
<Radio className="w-6 h-6 text-cyan-400 animate-pulse" />
|
||||
<span>Feed de Entrada & Publicador Multi-Marca</span>
|
||||
</h1>
|
||||
<p className="text-xs text-slate-400 mt-1">
|
||||
Recepción automática con IA, prevención de duplicados y distribución hacia las 4 marcas (<strong>Almeira Store</strong>, <strong>Sneaky</strong>, <strong>Zapply</strong>, <strong>DripZone</strong>).
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Target Brand Selector Pill Bar */}
|
||||
<div className="flex items-center space-x-1.5 bg-slate-900 border border-slate-800 p-1 rounded-2xl">
|
||||
<span className="text-[10px] font-bold text-slate-400 pl-2 pr-1 flex items-center gap-1">
|
||||
<Layers className="w-3 h-3" /> Destino:
|
||||
</span>
|
||||
<button
|
||||
onClick={() => setTargetBrand('all')}
|
||||
className={`text-xs font-bold px-2.5 py-1 rounded-xl transition-all cursor-pointer ${
|
||||
targetBrand === 'all' ? 'bg-white text-slate-950 shadow-sm' : 'text-slate-400 hover:text-white'
|
||||
}`}
|
||||
>
|
||||
4 Marcas
|
||||
</button>
|
||||
{Object.values(BRANDS).map((b) => (
|
||||
<button
|
||||
key={b.id}
|
||||
onClick={() => setTargetBrand(b.id as BrandId)}
|
||||
className={`text-xs font-bold px-2.5 py-1 rounded-xl transition-all cursor-pointer ${
|
||||
targetBrand === b.id ? `bg-gradient-to-r ${b.colors.primaryGradient} text-white shadow-sm` : 'text-slate-400 hover:text-white'
|
||||
}`}
|
||||
>
|
||||
{b.shortName}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{actionMessage && (
|
||||
<div className="bg-cyan-950/80 border border-cyan-500 text-cyan-200 px-4 py-3 rounded-2xl text-xs flex items-center justify-between animate-in fade-in shadow-lg">
|
||||
<span>{actionMessage}</span>
|
||||
<button onClick={() => setActionMessage(null)} className="font-bold text-white cursor-pointer">
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Live Simulator for Test Messages */}
|
||||
<div className="bg-slate-900 border border-slate-800 rounded-3xl p-5 sm:p-6 space-y-4">
|
||||
<div className="flex items-center space-x-2 text-cyan-400 font-bold text-xs uppercase tracking-wider">
|
||||
<Sparkles className="w-4 h-4 text-cyan-400" />
|
||||
<span>Simulador de Recepción en Tiempo Real</span>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-12 gap-3">
|
||||
<div className="md:col-span-4">
|
||||
<label className="block text-[11px] font-bold text-slate-400 mb-1">Canal de Entrada:</label>
|
||||
<select
|
||||
value={testGroup}
|
||||
onChange={(e) => setTestGroup(e.target.value)}
|
||||
className="w-full bg-slate-950 border border-slate-800 rounded-xl px-3 py-2 text-xs text-white outline-hidden"
|
||||
>
|
||||
<option value="Canal Mayorista A">Canal Mayorista A</option>
|
||||
<option value="Canal Mayorista B">Canal Mayorista B</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="md:col-span-8">
|
||||
<label className="block text-[11px] font-bold text-slate-400 mb-1">Texto del Mensaje de WhatsApp:</label>
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={testText}
|
||||
onChange={(e) => setTestText(e.target.value)}
|
||||
className="flex-1 bg-slate-950 border border-slate-800 rounded-xl px-3 py-2 text-xs text-white outline-hidden"
|
||||
/>
|
||||
<button
|
||||
onClick={handleSimulateIncoming}
|
||||
disabled={isProcessing}
|
||||
className="bg-gradient-to-r from-cyan-500 to-blue-600 hover:from-cyan-400 hover:to-blue-500 text-white font-bold text-xs px-4 py-2 rounded-xl flex items-center space-x-1.5 shrink-0 transition-all cursor-pointer shadow-md"
|
||||
>
|
||||
<Send className="w-3.5 h-3.5" />
|
||||
<span>Simular Recepción</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Intercepted Posts Feed */}
|
||||
<div className="space-y-4">
|
||||
<h2 className="font-bold text-white text-base">Mensajes Procesados Recientemente</h2>
|
||||
|
||||
<div className="grid grid-cols-1 gap-4">
|
||||
{posts.map((post) => {
|
||||
const extracted = post.extractedProduct;
|
||||
const duplicateCheck = extracted
|
||||
? checkProductDuplicate(
|
||||
{
|
||||
name: extracted.name,
|
||||
brand: extracted.brand,
|
||||
sizes: extracted.sizes,
|
||||
supplierPrice: extracted.supplierPrice,
|
||||
},
|
||||
products
|
||||
)
|
||||
: null;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={post.id}
|
||||
className="bg-slate-900 border border-slate-800 rounded-3xl p-5 sm:p-6 space-y-4 hover:border-slate-700 transition-all"
|
||||
>
|
||||
{/* Header of post */}
|
||||
<div className="flex flex-wrap items-center justify-between gap-2 border-b border-slate-800 pb-3">
|
||||
<div className="flex items-center space-x-2">
|
||||
<span className="bg-slate-800 text-slate-200 text-xs font-black px-2.5 py-1 rounded-full border border-slate-700">
|
||||
{post.channelName || 'Canal Mayorista'}
|
||||
</span>
|
||||
<span className="text-xs text-slate-400">{post.receivedAt}</span>
|
||||
</div>
|
||||
|
||||
{/* Anti-Duplicate Status Badge */}
|
||||
{duplicateCheck && (
|
||||
<div
|
||||
className={`text-xs font-bold px-3 py-1 rounded-full flex items-center gap-1.5 ${
|
||||
duplicateCheck.isDuplicate
|
||||
? 'bg-amber-500/20 text-amber-300 border border-amber-500/30'
|
||||
: 'bg-emerald-500/20 text-emerald-300 border border-emerald-500/30'
|
||||
}`}
|
||||
>
|
||||
{duplicateCheck.isDuplicate ? (
|
||||
<>
|
||||
<AlertTriangle className="w-3.5 h-3.5 text-amber-400" />
|
||||
<span>Duplicado Detectado ({Math.round(duplicateCheck.similarityScore * 100)}% Similitud)</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<ShieldCheck className="w-3.5 h-3.5 text-emerald-400" />
|
||||
<span>Producto Nuevo (0% Duplicados)</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Content Grid */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-12 gap-5">
|
||||
{/* Left: Raw WhatsApp Message (5 cols) */}
|
||||
<div className="md:col-span-5 bg-slate-950 rounded-2xl p-4 space-y-2 border border-slate-800">
|
||||
<span className="text-[10px] uppercase font-bold text-slate-400">Mensaje de Entrada:</span>
|
||||
<p className="text-xs text-slate-300 font-mono whitespace-pre-line leading-relaxed">
|
||||
{post.rawMessage}
|
||||
</p>
|
||||
{post.rawImages.length > 0 && (
|
||||
<div className="pt-2">
|
||||
<img
|
||||
src={post.rawImages[0]}
|
||||
alt=""
|
||||
className="w-24 h-24 rounded-xl object-cover bg-slate-900 border border-slate-700"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Right: AI Extracted & Pricing Calculation (7 cols) */}
|
||||
<div className="md:col-span-7 space-y-3">
|
||||
{extracted ? (
|
||||
<div className="bg-slate-950 rounded-2xl p-4 border border-slate-800 space-y-3">
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<span className="text-[10px] font-bold text-slate-400 uppercase">
|
||||
{extracted.brand} • {extracted.category}
|
||||
</span>
|
||||
<h3 className="text-base font-black text-white">{extracted.name}</h3>
|
||||
</div>
|
||||
<span className="text-xs font-mono bg-purple-500/20 text-purple-300 border border-purple-500/30 px-2 py-0.5 rounded font-bold">
|
||||
IA: {Math.round(extracted.confidence * 100)}% certeza
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Pricing breakdown */}
|
||||
<div className="grid grid-cols-2 gap-3 bg-slate-900 p-3 rounded-xl border border-slate-800">
|
||||
<div>
|
||||
<span className="text-[10px] text-slate-400 block font-semibold">Costo Base:</span>
|
||||
<span className="text-sm font-bold text-slate-300 font-mono">
|
||||
{formatCOP(extracted.supplierPrice)}
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-[10px] text-emerald-400 block font-bold">Precio Venta (+ Margen):</span>
|
||||
<span className="text-base font-black text-emerald-300 font-mono">
|
||||
{formatCOP(extracted.suggestedRetailPrice)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Sizes */}
|
||||
<div>
|
||||
<span className="text-[10px] text-slate-400 font-semibold uppercase block mb-1">
|
||||
Tallas extraídas:
|
||||
</span>
|
||||
<div className="flex gap-1 flex-wrap">
|
||||
{extracted.sizes.map((s) => (
|
||||
<span key={s} className="bg-slate-900 text-white font-bold text-xs px-2 py-0.5 rounded border border-slate-800">
|
||||
{s}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Duplicate warning note */}
|
||||
{duplicateCheck?.isDuplicate && (
|
||||
<div className="bg-amber-950/40 border border-amber-800/60 p-2.5 rounded-xl text-xs text-amber-200">
|
||||
{duplicateCheck.message}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex items-center gap-2 pt-2 border-t border-slate-800">
|
||||
<button
|
||||
onClick={() => handlePublishFacebook(post)}
|
||||
className="flex-1 bg-blue-600 hover:bg-blue-700 text-white font-bold text-xs py-2.5 px-3 rounded-xl flex items-center justify-center space-x-1.5 transition-all cursor-pointer shadow-sm"
|
||||
>
|
||||
<Share2 className="w-3.5 h-3.5" />
|
||||
<span>Publicar en Facebook</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => handleAddToStore(post)}
|
||||
className="flex-1 bg-gradient-to-r from-cyan-500 to-blue-600 hover:from-cyan-400 hover:to-blue-500 text-white font-bold text-xs py-2.5 px-3 rounded-xl flex items-center justify-center space-x-1.5 transition-all cursor-pointer shadow-md"
|
||||
>
|
||||
<Plus className="w-3.5 h-3.5" />
|
||||
<span>Publicar a la Tienda</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-xs text-slate-500">Extrayendo datos...</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
586
almeira-store-app/src/app/admin/grupos-whatsapp/page.tsx
Normal file
@ -0,0 +1,586 @@
|
||||
'use client';
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import {
|
||||
MessageSquare,
|
||||
RefreshCw,
|
||||
Search,
|
||||
CheckCircle2,
|
||||
XCircle,
|
||||
ShieldCheck,
|
||||
Zap,
|
||||
Radio,
|
||||
Sliders,
|
||||
Users,
|
||||
Sparkles,
|
||||
ExternalLink,
|
||||
Plus,
|
||||
ArrowUpRight,
|
||||
Filter,
|
||||
CheckSquare,
|
||||
Square,
|
||||
X
|
||||
} from 'lucide-react';
|
||||
import { MonitoredWhatsAppGroup } from '@/types';
|
||||
|
||||
export default function AdminWhatsAppGroupsPage() {
|
||||
const [groups, setGroups] = useState<MonitoredWhatsAppGroup[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [filterMode, setFilterMode] = useState<'all' | 'monitored' | 'unmonitored'>('all');
|
||||
const [toast, setToast] = useState<string | null>(null);
|
||||
const [scanningGroupId, setScanningGroupId] = useState<string | null>(null);
|
||||
|
||||
// Group Picker Modal State
|
||||
const [isPickerOpen, setIsPickerOpen] = useState(false);
|
||||
const [liveGroups, setLiveGroups] = useState<MonitoredWhatsAppGroup[]>([]);
|
||||
const [isLoadingLive, setIsLoadingLive] = useState(false);
|
||||
const [pickerSearch, setPickerSearch] = useState('');
|
||||
const [selectedJids, setSelectedJids] = useState<Set<string>>(new Set());
|
||||
|
||||
const fetchLocalGroups = async () => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const res = await fetch('/api/whatsapp-groups');
|
||||
const data = await res.json();
|
||||
if (data.success && Array.isArray(data.groups)) {
|
||||
setGroups(data.groups);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchLocalGroups();
|
||||
}, []);
|
||||
|
||||
const handleOpenPicker = async () => {
|
||||
setIsPickerOpen(true);
|
||||
setIsLoadingLive(true);
|
||||
try {
|
||||
const res = await fetch('/api/whatsapp-groups?fetchLive=true');
|
||||
const data = await res.json();
|
||||
if (data.success && Array.isArray(data.groups)) {
|
||||
setLiveGroups(data.groups);
|
||||
// Pre-select already monitored groups
|
||||
const initialSelected = new Set<string>();
|
||||
data.groups.forEach((g: MonitoredWhatsAppGroup) => {
|
||||
if (g.isMonitored) initialSelected.add(g.jid);
|
||||
});
|
||||
// Also add local groups
|
||||
groups.forEach((g) => {
|
||||
if (g.isMonitored) initialSelected.add(g.jid);
|
||||
});
|
||||
setSelectedJids(initialSelected);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
} finally {
|
||||
setIsLoadingLive(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleToggleSelectJid = (jid: string) => {
|
||||
setSelectedJids((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(jid)) next.delete(jid);
|
||||
else next.add(jid);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const handleSelectAllFiltered = (filtered: MonitoredWhatsAppGroup[]) => {
|
||||
setSelectedJids((prev) => {
|
||||
const next = new Set(prev);
|
||||
filtered.forEach((g) => next.add(g.jid));
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const handleDeselectAll = () => {
|
||||
setSelectedJids(new Set());
|
||||
};
|
||||
|
||||
const handleSaveSelectedGroups = async () => {
|
||||
setIsLoadingLive(true);
|
||||
try {
|
||||
const updatedGroups: MonitoredWhatsAppGroup[] = liveGroups.map((lg) => ({
|
||||
...lg,
|
||||
isMonitored: selectedJids.has(lg.jid),
|
||||
autoPublish: true,
|
||||
profitMarginOverride: 35,
|
||||
}));
|
||||
|
||||
const res = await fetch('/api/whatsapp-groups', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ groups: updatedGroups }),
|
||||
});
|
||||
|
||||
const data = await res.json();
|
||||
if (data.success) {
|
||||
setGroups(data.groups);
|
||||
setIsPickerOpen(false);
|
||||
setToast(`✅ ¡Se actualizaron los grupos monitoreados! (${selectedJids.size} activos)`);
|
||||
setTimeout(() => setToast(null), 4000);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
} finally {
|
||||
setIsLoadingLive(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleToggleGroup = async (group: MonitoredWhatsAppGroup) => {
|
||||
const nextState = !group.isMonitored;
|
||||
const updatedList = groups.map((g) =>
|
||||
g.jid === group.jid || g.id === group.id ? { ...g, isMonitored: nextState } : g
|
||||
);
|
||||
setGroups(updatedList);
|
||||
|
||||
try {
|
||||
await fetch('/api/whatsapp-groups', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
action: 'toggle',
|
||||
idOrJid: group.jid || group.id,
|
||||
isMonitored: nextState,
|
||||
}),
|
||||
});
|
||||
|
||||
setToast(
|
||||
nextState
|
||||
? `🟢 Grupo "${group.name}" ahora está siendo MONITOREADO en tiempo real.`
|
||||
: `⚪ Grupo "${group.name}" fue desactivado del monitoreo.`
|
||||
);
|
||||
setTimeout(() => setToast(null), 3500);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
}
|
||||
};
|
||||
|
||||
const handleScanGroup = async (group: MonitoredWhatsAppGroup) => {
|
||||
setScanningGroupId(group.id);
|
||||
try {
|
||||
await new Promise((r) => setTimeout(r, 1200));
|
||||
setToast(`✨ Mensajes recientes de "${group.name}" analizados con IA y catálogo actualizado.`);
|
||||
setTimeout(() => setToast(null), 4000);
|
||||
} finally {
|
||||
setScanningGroupId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const filteredGroups = groups.filter((g) => {
|
||||
const matchesSearch =
|
||||
g.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
g.jid.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
(g.notes && g.notes.toLowerCase().includes(searchQuery.toLowerCase()));
|
||||
|
||||
if (filterMode === 'monitored') return matchesSearch && g.isMonitored;
|
||||
if (filterMode === 'unmonitored') return matchesSearch && !g.isMonitored;
|
||||
return matchesSearch;
|
||||
});
|
||||
|
||||
const filteredPickerGroups = liveGroups.filter((g) =>
|
||||
g.name.toLowerCase().includes(pickerSearch.toLowerCase()) ||
|
||||
g.jid.toLowerCase().includes(pickerSearch.toLowerCase())
|
||||
);
|
||||
|
||||
const monitoredCount = groups.filter((g) => g.isMonitored).length;
|
||||
|
||||
return (
|
||||
<div className="space-y-6 max-w-6xl">
|
||||
{/* Header */}
|
||||
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
|
||||
<div>
|
||||
<h1 className="text-2xl font-black text-white flex items-center gap-2">
|
||||
<Radio className="w-6 h-6 text-cyan-400 animate-pulse" />
|
||||
<span>Monitoreo de Grupos de WhatsApp</span>
|
||||
</h1>
|
||||
<p className="text-xs text-slate-400 mt-1">
|
||||
Busca y selecciona los grupos de proveedores que el bot de <strong>Evolution API & Gemini</strong> debe escuchar 24/7 para auto-publicar zapatillas.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Big Search and Select Button */}
|
||||
<button
|
||||
onClick={handleOpenPicker}
|
||||
className="bg-gradient-to-r from-cyan-500 to-blue-600 hover:from-cyan-400 hover:to-blue-500 text-white font-black text-xs px-5 py-3 rounded-2xl flex items-center justify-center space-x-2 shadow-lg shadow-cyan-500/25 active:scale-95 transition-all cursor-pointer"
|
||||
>
|
||||
<Search className="w-4 h-4" />
|
||||
<span>Buscar y Seleccionar Grupos</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Toast Notification */}
|
||||
{toast && (
|
||||
<div className="bg-emerald-950/90 border border-emerald-500 text-emerald-200 px-4 py-3 rounded-2xl text-xs flex items-center justify-between shadow-xl animate-in fade-in">
|
||||
<span>{toast}</span>
|
||||
<button onClick={() => setToast(null)} className="font-bold text-white ml-3 cursor-pointer">
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Stats Bar */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
|
||||
<div className="bg-slate-900 border border-slate-800 rounded-2xl p-4 flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-[11px] font-bold text-slate-400">Grupos en tu WhatsApp</p>
|
||||
<p className="text-xl font-black text-white mt-0.5">{groups.length}</p>
|
||||
</div>
|
||||
<div className="w-10 h-10 rounded-xl bg-slate-800 flex items-center justify-center text-slate-300">
|
||||
<Users className="w-5 h-5" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-gradient-to-br from-slate-900 to-emerald-950/40 border border-emerald-500/30 rounded-2xl p-4 flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-[11px] font-bold text-emerald-400">Grupos Monitoreados (Activos)</p>
|
||||
<p className="text-xl font-black text-white mt-0.5">{monitoredCount}</p>
|
||||
</div>
|
||||
<div className="w-10 h-10 rounded-xl bg-emerald-500/20 text-emerald-400 flex items-center justify-center">
|
||||
<Radio className="w-5 h-5" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-slate-900 border border-slate-800 rounded-2xl p-4 flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-[11px] font-bold text-cyan-400">Evolution API (WhatsApp)</p>
|
||||
<p className="text-xs font-mono font-bold text-slate-300 mt-1 flex items-center gap-1.5">
|
||||
<span className="w-2 h-2 rounded-full bg-emerald-400 animate-ping" />
|
||||
<span>AutomataAlmeira (En línea)</span>
|
||||
</p>
|
||||
</div>
|
||||
<div className="w-10 h-10 rounded-xl bg-cyan-500/10 text-cyan-400 flex items-center justify-center">
|
||||
<Zap className="w-5 h-5" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Filter and Search Toolbar */}
|
||||
<div className="bg-slate-900 border border-slate-800 rounded-2xl p-3.5 flex flex-col sm:flex-row items-center justify-between gap-3">
|
||||
<div className="relative w-full sm:w-80">
|
||||
<Search className="w-4 h-4 text-slate-400 absolute left-3 top-1/2 -translate-y-1/2" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Buscar entre tus grupos activos..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="w-full bg-slate-950 border border-slate-800 rounded-xl pl-9 pr-3 py-2 text-xs text-white outline-hidden focus:border-cyan-400"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 w-full sm:w-auto">
|
||||
<button
|
||||
onClick={() => setFilterMode('all')}
|
||||
className={`text-xs font-bold px-3 py-1.5 rounded-xl transition-all cursor-pointer ${
|
||||
filterMode === 'all'
|
||||
? 'bg-slate-800 text-white border border-slate-700'
|
||||
: 'text-slate-400 hover:text-white'
|
||||
}`}
|
||||
>
|
||||
Todos ({groups.length})
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setFilterMode('monitored')}
|
||||
className={`text-xs font-bold px-3 py-1.5 rounded-xl transition-all cursor-pointer ${
|
||||
filterMode === 'monitored'
|
||||
? 'bg-emerald-500/20 text-emerald-300 border border-emerald-500/30'
|
||||
: 'text-slate-400 hover:text-white'
|
||||
}`}
|
||||
>
|
||||
Monitoreados ({monitoredCount})
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setFilterMode('unmonitored')}
|
||||
className={`text-xs font-bold px-3 py-1.5 rounded-xl transition-all cursor-pointer ${
|
||||
filterMode === 'unmonitored'
|
||||
? 'bg-slate-800 text-white border border-slate-700'
|
||||
: 'text-slate-400 hover:text-white'
|
||||
}`}
|
||||
>
|
||||
Inactivos ({groups.length - monitoredCount})
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Active Monitored Groups List */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{isLoading ? (
|
||||
<div className="col-span-full py-12 text-center text-xs text-slate-400 space-y-2">
|
||||
<RefreshCw className="w-6 h-6 animate-spin mx-auto text-cyan-400" />
|
||||
<p>Cargando grupos de proveedores...</p>
|
||||
</div>
|
||||
) : filteredGroups.length === 0 ? (
|
||||
<div className="col-span-full py-12 text-center text-xs text-slate-400 space-y-3 bg-slate-900 border border-slate-800 rounded-3xl">
|
||||
<MessageSquare className="w-8 h-8 mx-auto text-slate-600" />
|
||||
<p>No tienes grupos seleccionados para monitorear.</p>
|
||||
<button
|
||||
onClick={handleOpenPicker}
|
||||
className="bg-cyan-600 hover:bg-cyan-500 text-white font-bold text-xs px-4 py-2.5 rounded-xl shadow-lg cursor-pointer"
|
||||
>
|
||||
Buscar y Seleccionar Grupos desde WhatsApp
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
filteredGroups.map((group) => {
|
||||
const isMonitored = group.isMonitored;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={group.id || group.jid}
|
||||
className={`p-5 rounded-3xl border transition-all space-y-4 ${
|
||||
isMonitored
|
||||
? 'bg-gradient-to-br from-slate-900 to-slate-900/90 border-emerald-500/40 shadow-lg shadow-emerald-950/20 ring-1 ring-emerald-500/20'
|
||||
: 'bg-slate-900/60 border-slate-800 hover:border-slate-700 opacity-80'
|
||||
}`}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span
|
||||
className={`w-2.5 h-2.5 rounded-full shrink-0 ${
|
||||
isMonitored ? 'bg-emerald-400 animate-pulse' : 'bg-slate-600'
|
||||
}`}
|
||||
/>
|
||||
<h3 className="font-black text-white text-sm leading-snug line-clamp-1">
|
||||
{group.name}
|
||||
</h3>
|
||||
</div>
|
||||
<p className="text-[11px] font-mono text-slate-400 pl-4 mt-0.5 truncate">
|
||||
{group.jid}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Toggle Switch */}
|
||||
<button
|
||||
onClick={() => handleToggleGroup(group)}
|
||||
className={`shrink-0 text-xs font-bold px-3 py-1.5 rounded-xl border flex items-center gap-1.5 transition-all cursor-pointer ${
|
||||
isMonitored
|
||||
? 'bg-emerald-500 text-slate-950 border-emerald-400 shadow-md font-black'
|
||||
: 'bg-slate-800 text-slate-400 border-slate-700 hover:text-white'
|
||||
}`}
|
||||
>
|
||||
{isMonitored ? (
|
||||
<>
|
||||
<CheckCircle2 className="w-3.5 h-3.5 fill-slate-950 text-emerald-500" />
|
||||
<span>Monitoreando</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<XCircle className="w-3.5 h-3.5" />
|
||||
<span>Inactivo</span>
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Info Pills */}
|
||||
<div className="grid grid-cols-2 gap-2 text-[11px] pt-1">
|
||||
<div className="bg-slate-950/80 p-2.5 rounded-xl border border-slate-800">
|
||||
<span className="text-slate-400 block text-[10px]">Margen Aplicado</span>
|
||||
<span className="font-bold text-white">
|
||||
+{group.profitMarginOverride || 35}% (Redondeo a $5.000)
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="bg-slate-950/80 p-2.5 rounded-xl border border-slate-800">
|
||||
<span className="text-slate-400 block text-[10px]">Modo de Ingreso</span>
|
||||
<span className="font-bold text-emerald-300 flex items-center gap-1">
|
||||
<Zap className="w-3 h-3" /> Auto-Publicar Directo
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{group.notes && (
|
||||
<p className="text-[11px] text-slate-400 bg-slate-950/40 p-2 rounded-lg border border-slate-800/60">
|
||||
ℹ️ {group.notes}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Action footer */}
|
||||
<div className="flex items-center justify-between pt-2 border-t border-slate-800/80">
|
||||
<span className="text-[10px] text-slate-500 font-mono">
|
||||
{group.participantsCount ? `👥 ${group.participantsCount} miembros` : 'Canal Mayorista'}
|
||||
</span>
|
||||
|
||||
<button
|
||||
onClick={() => handleScanGroup(group)}
|
||||
disabled={scanningGroupId === group.id}
|
||||
className="text-xs font-bold text-slate-300 hover:text-white bg-slate-800 hover:bg-slate-700 px-3 py-1.5 rounded-xl flex items-center gap-1.5 transition-all cursor-pointer disabled:opacity-50"
|
||||
>
|
||||
<Sparkles className={`w-3.5 h-3.5 text-cyan-400 ${scanningGroupId === group.id ? 'animate-spin' : ''}`} />
|
||||
<span>{scanningGroupId === group.id ? 'Extrayendo...' : 'Escanear Mensajes'}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ========================================================= */}
|
||||
{/* MODAL: Group Picker (Buscar y Seleccionar Grupos) */}
|
||||
{/* ========================================================= */}
|
||||
{isPickerOpen && (
|
||||
<div className="fixed inset-0 z-50 bg-black/80 backdrop-blur-xs flex items-center justify-center p-4 animate-in fade-in duration-200">
|
||||
<div className="bg-slate-900 border border-slate-800 rounded-3xl max-w-2xl w-full max-h-[90vh] flex flex-col shadow-2xl overflow-hidden animate-in zoom-in-95">
|
||||
{/* Modal Header */}
|
||||
<div className="p-5 border-b border-slate-800 flex items-center justify-between bg-slate-950">
|
||||
<div className="flex items-center space-x-2.5">
|
||||
<div className="w-10 h-10 rounded-2xl bg-cyan-500/20 text-cyan-400 flex items-center justify-center">
|
||||
<MessageSquare className="w-5 h-5" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-base font-black text-white">Seleccionar Grupos de WhatsApp</h2>
|
||||
<p className="text-xs text-slate-400">
|
||||
Marca los grupos que deseas monitorear con IA para auto-publicar calzado.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setIsPickerOpen(false)}
|
||||
className="w-8 h-8 rounded-full bg-slate-800 hover:bg-slate-700 text-slate-400 hover:text-white flex items-center justify-center transition-all cursor-pointer"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Search and Action Toolbar in Modal */}
|
||||
<div className="p-4 border-b border-slate-800 bg-slate-900/50 space-y-3">
|
||||
<div className="relative">
|
||||
<Search className="w-4 h-4 text-slate-400 absolute left-3 top-1/2 -translate-y-1/2" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Buscar entre todos tus grupos de WhatsApp (ej: Jovic, Mío, Bodega)..."
|
||||
value={pickerSearch}
|
||||
onChange={(e) => setPickerSearch(e.target.value)}
|
||||
className="w-full bg-slate-950 border border-slate-800 rounded-xl pl-9 pr-3.5 py-2.5 text-xs text-white outline-hidden focus:border-cyan-400 font-semibold"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between text-xs">
|
||||
<span className="text-slate-400 font-semibold">
|
||||
Mostrando <strong className="text-white">{filteredPickerGroups.length}</strong> grupos encontrados
|
||||
</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => handleSelectAllFiltered(filteredPickerGroups)}
|
||||
className="text-cyan-400 hover:text-cyan-300 font-bold hover:underline cursor-pointer"
|
||||
>
|
||||
Seleccionar visibles
|
||||
</button>
|
||||
<span className="text-slate-600">•</span>
|
||||
<button
|
||||
onClick={handleDeselectAll}
|
||||
className="text-slate-400 hover:text-white font-bold hover:underline cursor-pointer"
|
||||
>
|
||||
Deseleccionar todos
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Groups Scrollable List */}
|
||||
<div className="flex-1 overflow-y-auto p-4 space-y-2">
|
||||
{isLoadingLive ? (
|
||||
<div className="py-16 text-center text-xs text-slate-400 space-y-3">
|
||||
<RefreshCw className="w-8 h-8 animate-spin mx-auto text-cyan-400" />
|
||||
<p className="font-bold text-white">Consultando grupos en vivo desde Evolution API...</p>
|
||||
<p className="text-[11px] text-slate-500">Conectando con WhatsApp...</p>
|
||||
</div>
|
||||
) : filteredPickerGroups.length === 0 ? (
|
||||
<div className="py-12 text-center text-xs text-slate-400 space-y-2">
|
||||
<p>No se encontraron grupos con el término "{pickerSearch}".</p>
|
||||
</div>
|
||||
) : (
|
||||
filteredPickerGroups.map((group) => {
|
||||
const isSelected = selectedJids.has(group.jid);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={group.jid}
|
||||
onClick={() => handleToggleSelectJid(group.jid)}
|
||||
className={`p-3.5 rounded-2xl border transition-all cursor-pointer flex items-center justify-between gap-3 ${
|
||||
isSelected
|
||||
? 'bg-cyan-950/30 border-cyan-500/50 shadow-md ring-1 ring-cyan-500/20'
|
||||
: 'bg-slate-950 border-slate-800 hover:border-slate-700'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center space-x-3 flex-1 min-w-0">
|
||||
<div
|
||||
className={`w-5 h-5 rounded-lg border flex items-center justify-center shrink-0 transition-all ${
|
||||
isSelected
|
||||
? 'bg-cyan-500 border-cyan-400 text-slate-950'
|
||||
: 'border-slate-700 bg-slate-900'
|
||||
}`}
|
||||
>
|
||||
{isSelected && <CheckSquare className="w-3.5 h-3.5 fill-current" />}
|
||||
</div>
|
||||
|
||||
<div className="min-w-0">
|
||||
<h4 className="font-bold text-white text-xs truncate">
|
||||
{group.name}
|
||||
</h4>
|
||||
<p className="text-[10px] font-mono text-slate-400 truncate">
|
||||
{group.jid}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="shrink-0 flex items-center gap-2">
|
||||
{isSelected ? (
|
||||
<span className="bg-emerald-500/20 text-emerald-300 border border-emerald-500/30 text-[10px] font-black px-2 py-0.5 rounded-full uppercase">
|
||||
Monitorear
|
||||
</span>
|
||||
) : (
|
||||
<span className="bg-slate-800 text-slate-400 text-[10px] font-bold px-2 py-0.5 rounded-full">
|
||||
Ignorar
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Modal Footer */}
|
||||
<div className="p-4 border-t border-slate-800 bg-slate-950 flex items-center justify-between">
|
||||
<div className="text-xs">
|
||||
<span className="text-slate-400">Seleccionados: </span>
|
||||
<strong className="text-cyan-400 font-mono text-sm">{selectedJids.size}</strong>
|
||||
<span className="text-slate-400"> grupos para monitorear</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsPickerOpen(false)}
|
||||
className="px-4 py-2.5 rounded-xl bg-slate-800 hover:bg-slate-700 text-slate-300 font-bold text-xs cursor-pointer transition-all"
|
||||
>
|
||||
Cancelar
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSaveSelectedGroups}
|
||||
disabled={isLoadingLive}
|
||||
className="px-5 py-2.5 rounded-xl bg-cyan-600 hover:bg-cyan-500 text-white font-bold text-xs flex items-center space-x-1.5 shadow-lg shadow-cyan-600/30 cursor-pointer transition-all active:scale-95 disabled:opacity-50"
|
||||
>
|
||||
<CheckCircle2 className="w-4 h-4" />
|
||||
<span>Guardar y Monitorear ({selectedJids.size})</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
99
almeira-store-app/src/app/admin/layout.tsx
Normal file
@ -0,0 +1,99 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import Link from 'next/link';
|
||||
import { usePathname } from 'next/navigation';
|
||||
import {
|
||||
LayoutDashboard,
|
||||
Radio,
|
||||
Package,
|
||||
MapPin,
|
||||
CreditCard,
|
||||
ShoppingBag,
|
||||
ArrowLeft,
|
||||
Sparkles,
|
||||
Store,
|
||||
Users,
|
||||
MessageSquare,
|
||||
} from 'lucide-react';
|
||||
|
||||
export default function AdminLayout({ children }: { children: React.ReactNode }) {
|
||||
const pathname = usePathname();
|
||||
|
||||
const NAV_ITEMS = [
|
||||
{ href: '/admin', label: 'Resumen & KPIs', icon: LayoutDashboard },
|
||||
{ href: '/admin/grupos-whatsapp', label: 'Grupos WhatsApp', icon: MessageSquare, badge: 'Monitoreo 24/7' },
|
||||
{ href: '/admin/usuarios', label: 'Control de Usuarios', icon: Users, badge: 'Roles & Accesos' },
|
||||
{ href: '/admin/configuracion', label: 'Datos de la Tienda', icon: Store },
|
||||
{ href: '/admin/feed-whatsapp', label: 'Feed WhatsApp & IA', icon: Radio, badge: 'Anti-Duplicados' },
|
||||
{ href: '/admin/productos', label: 'Catálogo de Zapatillas', icon: Package },
|
||||
{ href: '/admin/ciudades', label: 'Ciudades & Fletes', icon: MapPin },
|
||||
{ href: '/admin/metodos-pago', label: 'Medios de Pago & Anticipos', icon: CreditCard },
|
||||
{ href: '/admin/pedidos', label: 'Pedidos & Logística', icon: ShoppingBag },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-slate-950 text-slate-100 flex flex-col md:flex-row font-sans">
|
||||
{/* Sidebar */}
|
||||
<aside className="w-full md:w-64 bg-slate-900/90 backdrop-blur-xl border-r border-cyan-500/20 flex flex-col justify-between shrink-0 p-4">
|
||||
<div className="space-y-6">
|
||||
{/* Logo */}
|
||||
<div className="flex items-center space-x-2.5 px-2">
|
||||
<div className="w-9 h-9 rounded-xl bg-gradient-to-br from-cyan-500 to-blue-600 flex items-center justify-center text-white shadow-[0_0_15px_rgba(6,182,212,0.4)]">
|
||||
<span className="font-black text-sm">A</span>
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="font-black text-sm text-white tracking-wider">ALMEIRA STORE</h2>
|
||||
<p className="text-[10px] text-cyan-400 font-bold uppercase tracking-widest">Backoffice Admin</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Navigation */}
|
||||
<nav className="space-y-1">
|
||||
{NAV_ITEMS.map((item) => {
|
||||
const Icon = item.icon;
|
||||
const isActive = pathname === item.href;
|
||||
return (
|
||||
<Link
|
||||
key={item.href}
|
||||
href={item.href}
|
||||
className={`flex items-center justify-between px-3 py-2.5 rounded-xl text-xs font-bold transition-all ${
|
||||
isActive
|
||||
? 'bg-gradient-to-r from-cyan-500 to-blue-600 text-white shadow-[0_0_15px_rgba(6,182,212,0.4)]'
|
||||
: 'text-slate-400 hover:text-white hover:bg-slate-800'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center space-x-2.5">
|
||||
<Icon className="w-4 h-4" />
|
||||
<span>{item.label}</span>
|
||||
</div>
|
||||
{item.badge && (
|
||||
<span className="text-[9px] bg-cyan-950 text-cyan-300 border border-cyan-500/30 px-1.5 py-0.5 rounded font-mono">
|
||||
{item.badge}
|
||||
</span>
|
||||
)}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
{/* Return to Store */}
|
||||
<div className="pt-4 border-t border-slate-800">
|
||||
<Link
|
||||
href="/"
|
||||
className="flex items-center space-x-2 text-xs font-semibold text-slate-400 hover:text-cyan-300 px-3 py-2 rounded-xl hover:bg-slate-800 transition-all"
|
||||
>
|
||||
<ArrowLeft className="w-4 h-4" />
|
||||
<span>Volver a la Tienda</span>
|
||||
</Link>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
{/* Main Content Area */}
|
||||
<main className="flex-1 p-4 sm:p-8 overflow-y-auto bg-slate-950">
|
||||
{children}
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
163
almeira-store-app/src/app/admin/metodos-pago/page.tsx
Normal file
@ -0,0 +1,163 @@
|
||||
'use client';
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { CreditCard, Zap, Save, Check, ShieldCheck, QrCode } from 'lucide-react';
|
||||
import { PaymentMethod } from '@/types';
|
||||
import { INITIAL_PAYMENT_METHODS } from '@/lib/data-service';
|
||||
import { formatCOP } from '@/lib/pricing-engine';
|
||||
|
||||
export default function AdminPaymentMethodsPage() {
|
||||
const [methods, setMethods] = useState<PaymentMethod[]>(INITIAL_PAYMENT_METHODS);
|
||||
const [message, setMessage] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
fetch('/api/payment-methods')
|
||||
.then((r) => r.json())
|
||||
.then((d) => d.success && setMethods(d.paymentMethods))
|
||||
.catch(() => {});
|
||||
}, []);
|
||||
|
||||
const handleToggleActive = async (method: PaymentMethod) => {
|
||||
const updated = { ...method, isActive: !method.isActive };
|
||||
setMethods((prev) => prev.map((m) => (m.id === method.id ? updated : m)));
|
||||
|
||||
await fetch('/api/payment-methods', {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ id: method.id, updates: { isActive: updated.isActive } }),
|
||||
});
|
||||
setMessage(`Método "${method.name}" ${updated.isActive ? 'activado' : 'desactivado'}.`);
|
||||
};
|
||||
|
||||
const handleUpdateField = (id: string, field: keyof PaymentMethod, val: any) => {
|
||||
setMethods((prev) =>
|
||||
prev.map((m) => (m.id === id ? { ...m, [field]: val } : m))
|
||||
);
|
||||
};
|
||||
|
||||
const handleSaveMethod = async (method: PaymentMethod) => {
|
||||
await fetch('/api/payment-methods', {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ id: method.id, updates: method }),
|
||||
});
|
||||
setMessage(`✅ Cambios guardados para "${method.name}".`);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div>
|
||||
<h1 className="text-2xl font-black text-white flex items-center gap-2">
|
||||
<CreditCard className="w-6 h-6 text-cyan-400" />
|
||||
<span>Configuración de Medios de Pago & Anticipos</span>
|
||||
</h1>
|
||||
<p className="text-xs text-slate-400 mt-1">
|
||||
Habilita métodos de pago, configura el valor de separación de $10.000 COP y personaliza los datos de Nequi / Daviplata.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{message && (
|
||||
<div className="bg-emerald-950/80 border border-emerald-800 text-emerald-300 px-4 py-3 rounded-2xl text-xs flex items-center justify-between">
|
||||
<span>{message}</span>
|
||||
<button onClick={() => setMessage(null)} className="font-bold text-white cursor-pointer">
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Methods Cards */}
|
||||
<div className="space-y-4">
|
||||
{methods.map((method) => (
|
||||
<div
|
||||
key={method.id}
|
||||
className="bg-slate-950 border border-slate-800 rounded-3xl p-5 sm:p-6 space-y-4"
|
||||
>
|
||||
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-3 border-b border-slate-800/80 pb-4">
|
||||
<div className="flex items-center space-x-3">
|
||||
<div className="w-10 h-10 rounded-xl bg-slate-900 border border-slate-800 text-cyan-400 flex items-center justify-center font-bold">
|
||||
{method.type === 'contraentrega' ? <Zap className="w-5 h-5 fill-cyan-400" /> : <CreditCard className="w-5 h-5" />}
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-bold text-white text-base">{method.name}</h3>
|
||||
<span className="text-xs text-slate-500 font-mono">Tipo: {method.type}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-2">
|
||||
<button
|
||||
onClick={() => handleToggleActive(method)}
|
||||
className={`text-xs font-bold px-3 py-1.5 rounded-xl transition-all cursor-pointer ${
|
||||
method.isActive
|
||||
? 'bg-emerald-500/20 text-emerald-400 hover:bg-emerald-500/30'
|
||||
: 'bg-slate-800 text-slate-500 hover:bg-slate-700'
|
||||
}`}
|
||||
>
|
||||
{method.isActive ? 'Habilitado en Tienda' : 'Deshabilitado'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-[11px] font-bold text-slate-400 mb-1">Nombre Visible:</label>
|
||||
<input
|
||||
type="text"
|
||||
value={method.name}
|
||||
onChange={(e) => handleUpdateField(method.id, 'name', e.target.value)}
|
||||
className="w-full bg-slate-900 border border-slate-800 rounded-xl px-3 py-2 text-xs text-white outline-hidden"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-[11px] font-bold text-slate-400 mb-1">Descripción para el Cliente:</label>
|
||||
<input
|
||||
type="text"
|
||||
value={method.description}
|
||||
onChange={(e) => handleUpdateField(method.id, 'description', e.target.value)}
|
||||
className="w-full bg-slate-900 border border-slate-800 rounded-xl px-3 py-2 text-xs text-white outline-hidden"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{method.requiresDeposit && (
|
||||
<div>
|
||||
<label className="block text-[11px] font-bold text-amber-400 mb-1">Valor de la Separación (COP):</label>
|
||||
<input
|
||||
type="number"
|
||||
value={method.depositAmount}
|
||||
onChange={(e) => handleUpdateField(method.id, 'depositAmount', parseInt(e.target.value, 10) || 0)}
|
||||
className="w-full bg-slate-900 border border-slate-800 rounded-xl px-3 py-2 text-xs text-white outline-hidden font-bold"
|
||||
/>
|
||||
<span className="text-[10px] text-slate-400 mt-1 block">
|
||||
(El saldo restante se cobra contraentrega al recibir)
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label className="block text-[11px] font-bold text-slate-400 mb-1">Cuentas / Números de Pago (Nequi / Daviplata):</label>
|
||||
<input
|
||||
type="text"
|
||||
value={method.accountInfo || ''}
|
||||
onChange={(e) => handleUpdateField(method.id, 'accountInfo', e.target.value)}
|
||||
className="w-full bg-slate-900 border border-slate-800 rounded-xl px-3 py-2 text-xs text-white outline-hidden"
|
||||
placeholder="Ej. Nequi / Daviplata: 316 745 5877"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end pt-2">
|
||||
<button
|
||||
onClick={() => handleSaveMethod(method)}
|
||||
className="bg-cyan-600 hover:bg-cyan-700 text-white font-bold text-xs px-4 py-2 rounded-xl flex items-center space-x-1.5 transition-all cursor-pointer shadow-lg shadow-cyan-600/30"
|
||||
>
|
||||
<Save className="w-3.5 h-3.5" />
|
||||
<span>Guardar Configuración</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
159
almeira-store-app/src/app/admin/page.tsx
Normal file
@ -0,0 +1,159 @@
|
||||
'use client';
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { Package, Radio, MapPin, Zap, CheckCircle2, ArrowUpRight, Sparkles } from 'lucide-react';
|
||||
import { INITIAL_PRODUCTS, INITIAL_CITIES, INITIAL_PAYMENT_METHODS, INITIAL_SUPPLIER_POSTS } from '@/lib/data-service';
|
||||
|
||||
export default function AdminDashboardPage() {
|
||||
const [productsCount] = useState(INITIAL_PRODUCTS.length);
|
||||
const [postsCount] = useState(INITIAL_SUPPLIER_POSTS.length);
|
||||
const [citiesCount] = useState(INITIAL_CITIES.length);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
|
||||
<div>
|
||||
<h1 className="text-2xl font-black text-white flex items-center gap-2">
|
||||
<Sparkles className="w-6 h-6 text-cyan-400" />
|
||||
<span>Centro de Control • Almeira Store</span>
|
||||
</h1>
|
||||
<p className="text-xs text-slate-400 mt-1">
|
||||
Automatización inteligente de calzado, publicaciones a Facebook y gestión de despachos.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-2">
|
||||
<Link
|
||||
href="/admin/feed-whatsapp"
|
||||
className="bg-gradient-to-r from-cyan-500 to-blue-600 hover:from-cyan-400 hover:to-blue-500 text-white text-xs font-bold px-4 py-2.5 rounded-xl flex items-center space-x-1.5 shadow-[0_0_20px_rgba(6,182,212,0.3)]"
|
||||
>
|
||||
<Radio className="w-4 h-4 animate-pulse" />
|
||||
<span>Ver Feed de WhatsApp</span>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* KPI Cards */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<div className="bg-slate-900 border border-slate-800 p-5 rounded-3xl space-y-2">
|
||||
<div className="flex items-center justify-between text-slate-400">
|
||||
<span className="text-xs font-bold uppercase">Zapatillas Activas</span>
|
||||
<Package className="w-4 h-4 text-cyan-400" />
|
||||
</div>
|
||||
<div className="text-3xl font-black text-white">{productsCount}</div>
|
||||
<p className="text-[11px] text-cyan-300 font-semibold flex items-center gap-1">
|
||||
<CheckCircle2 className="w-3 h-3" /> Catálogo sincronizado
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-slate-900 border border-slate-800 p-5 rounded-3xl space-y-2">
|
||||
<div className="flex items-center justify-between text-slate-400">
|
||||
<span className="text-xs font-bold uppercase">Margen Promedio</span>
|
||||
<Zap className="w-4 h-4 text-pink-400" />
|
||||
</div>
|
||||
<div className="text-3xl font-black text-transparent bg-clip-text bg-gradient-to-r from-cyan-400 to-pink-400">
|
||||
+$30.000 COP
|
||||
</div>
|
||||
<p className="text-[11px] text-slate-400 font-medium">
|
||||
(Costo: $90k ➔ Venta: $120k)
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-slate-900 border border-slate-800 p-5 rounded-3xl space-y-2">
|
||||
<div className="flex items-center justify-between text-slate-400">
|
||||
<span className="text-xs font-bold uppercase">Entradas Procesadas</span>
|
||||
<Radio className="w-4 h-4 text-purple-400" />
|
||||
</div>
|
||||
<div className="text-3xl font-black text-white">{postsCount}</div>
|
||||
<p className="text-[11px] text-purple-300 font-medium">
|
||||
IA Gemini + Anti-duplicados
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-slate-900 border border-slate-800 p-5 rounded-3xl space-y-2">
|
||||
<div className="flex items-center justify-between text-slate-400">
|
||||
<span className="text-xs font-bold uppercase">Ciudades Habilitadas</span>
|
||||
<MapPin className="w-4 h-4 text-blue-400" />
|
||||
</div>
|
||||
<div className="text-3xl font-black text-white">{citiesCount}</div>
|
||||
<p className="text-[11px] text-blue-300 font-semibold">
|
||||
Contraentrega activo en Cali
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Quick Actions & Automation Status */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
{/* Left: WhatsApp Evolution API Status */}
|
||||
<div className="bg-slate-900 border border-slate-800 rounded-3xl p-6 space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className="w-3 h-3 rounded-full bg-cyan-400 animate-ping" />
|
||||
<h3 className="font-black text-white text-base">Estado del Listener de WhatsApp</h3>
|
||||
</div>
|
||||
<span className="text-xs font-bold bg-cyan-500/20 text-cyan-300 border border-cyan-500/30 px-2.5 py-1 rounded-full">
|
||||
Activo
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="bg-slate-950 rounded-2xl p-4 space-y-2.5 text-xs">
|
||||
<div className="flex justify-between text-slate-400">
|
||||
<span>Instancia Evolution API:</span>
|
||||
<strong className="text-white">AutomataAlmeira</strong>
|
||||
</div>
|
||||
<div className="flex justify-between text-slate-400">
|
||||
<span>Servidor:</span>
|
||||
<span className="text-cyan-300 font-mono text-[11px]">evolution.almeira.com.co</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-slate-400">
|
||||
<span>Motor de Inteligencia Artificial:</span>
|
||||
<strong className="text-pink-400">Gemini 2.5 Flash</strong>
|
||||
</div>
|
||||
<div className="flex justify-between text-slate-400">
|
||||
<span>Filtro Anti-Duplicados:</span>
|
||||
<strong className="text-cyan-400">Activo (>75% Similitud)</strong>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Link
|
||||
href="/admin/feed-whatsapp"
|
||||
className="w-full bg-slate-800 hover:bg-slate-700 text-white font-bold text-xs py-2.5 px-4 rounded-xl flex items-center justify-center space-x-2 transition-all border border-slate-700"
|
||||
>
|
||||
<span>Ver últimos mensajes procesados</span>
|
||||
<ArrowUpRight className="w-4 h-4" />
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Right: Meta / Facebook Automation */}
|
||||
<div className="bg-slate-900 border border-slate-800 rounded-3xl p-6 space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="font-black text-white text-base">Publicador a Fanpage de Facebook</h3>
|
||||
<span className="text-xs font-bold bg-blue-500/20 text-blue-400 border border-blue-500/30 px-2.5 py-1 rounded-full">
|
||||
Listo para Publicar
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="bg-slate-950 rounded-2xl p-4 text-xs space-y-2 text-slate-300">
|
||||
<p className="font-semibold text-white">✨ Plantilla de Copy Vendedor Integrada:</p>
|
||||
<p className="text-slate-400 italic bg-slate-900 p-2.5 rounded-xl border border-slate-800 text-[11px] leading-relaxed">
|
||||
"✨ ALMEIRA STORE • NUEVA COLECCIÓN EXCLUSIVA ✨<br />
|
||||
👟 NIKE DUNK LOW PANDA<br />
|
||||
📏 Tallas: 37 a 43 | 💎 Precio: $120.000 COP<br />
|
||||
🚚 ¡Entrega a Domicilio en Cali con Pago Contraentrega!"
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Link
|
||||
href="/admin/feed-whatsapp"
|
||||
className="w-full bg-gradient-to-r from-blue-600 to-cyan-600 hover:opacity-95 text-white font-bold text-xs py-2.5 px-4 rounded-xl flex items-center justify-center space-x-2 transition-all shadow-[0_0_15px_rgba(6,182,212,0.3)]"
|
||||
>
|
||||
<span>Publicar Producto de Prueba en Facebook</span>
|
||||
<ArrowUpRight className="w-4 h-4" />
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
283
almeira-store-app/src/app/admin/pedidos/page.tsx
Normal file
@ -0,0 +1,283 @@
|
||||
'use client';
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { ShoppingBag, Truck, CheckCircle2, Clock, Phone, MapPin, Search, MessageCircle, Send, ShieldCheck, UserCheck } from 'lucide-react';
|
||||
import { Order, OrderItem } from '@/types';
|
||||
import { formatCOP } from '@/lib/pricing-engine';
|
||||
|
||||
export default function AdminOrdersPage() {
|
||||
const [orders, setOrders] = useState<Order[]>([]);
|
||||
const [search, setSearch] = useState('');
|
||||
const [statusFilter, setStatusFilter] = useState<string>('all');
|
||||
|
||||
useEffect(() => {
|
||||
fetch('/api/orders')
|
||||
.then((r) => r.json())
|
||||
.then((d) => d.success && setOrders(d.orders))
|
||||
.catch(() => {});
|
||||
}, []);
|
||||
|
||||
const handleStatusChange = async (orderId: string, newStatus: Order['status']) => {
|
||||
setOrders((prev) =>
|
||||
prev.map((o) => (o.id === orderId ? { ...o, status: newStatus } : o))
|
||||
);
|
||||
|
||||
await fetch('/api/orders', {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ id: orderId, status: newStatus }),
|
||||
}).catch(() => {});
|
||||
};
|
||||
|
||||
const handleNotifySupplier = (order: Order, item: OrderItem) => {
|
||||
const supplierPhone = item.supplierPhone || '573134848203';
|
||||
const supplierName = item.supplierName || 'Proveedor Mayorista';
|
||||
const supplierCost = item.supplierPrice ? formatCOP(item.supplierPrice) : 'Precio Mayorista';
|
||||
|
||||
const message = `👋 Hola *${supplierName}*!\n\nPor favor *sepárame este par* para despacho inmediato:\n\n👟 *Modelo:* ${item.productName}\n📏 *Talla:* ${item.size}\n📦 *Cantidad:* ${item.quantity} par(es)\n💵 *Costo Base:* ${supplierCost}\n\n🏷️ *Orden Interna:* #${order.id}\n📍 *Ciudad Destino:* ${order.cityName}\n\n¿Me confirmas si lo tienes listo para recoger / enviar? ¡Muchas gracias!`;
|
||||
|
||||
const url = `https://wa.me/${supplierPhone}?text=${encodeURIComponent(message)}`;
|
||||
window.open(url, '_blank');
|
||||
|
||||
// Auto-update status to supplier_notified if still pending
|
||||
if (order.status === 'pending' || order.status === 'deposit_received') {
|
||||
handleStatusChange(order.id, 'supplier_notified');
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusBadge = (status: Order['status']) => {
|
||||
switch (status) {
|
||||
case 'pending':
|
||||
return <span className="bg-amber-500/20 text-amber-400 border border-amber-500/30 font-bold px-2.5 py-1 rounded-full text-[10px]">Pendiente Confirmar</span>;
|
||||
case 'deposit_received':
|
||||
return <span className="bg-blue-500/20 text-blue-400 border border-blue-500/30 font-bold px-2.5 py-1 rounded-full text-[10px]">Separación $10k Pagada</span>;
|
||||
case 'supplier_notified':
|
||||
return <span className="bg-cyan-500/20 text-cyan-300 border border-cyan-500/30 font-bold px-2.5 py-1 rounded-full text-[10px] flex items-center gap-1"><CheckCircle2 className="w-3 h-3 text-cyan-400" /> Proveedor Notificado</span>;
|
||||
case 'dispatched':
|
||||
return <span className="bg-purple-500/20 text-purple-400 border border-purple-500/30 font-bold px-2.5 py-1 rounded-full text-[10px]">En Ruta con Mensajero</span>;
|
||||
case 'delivered':
|
||||
return <span className="bg-emerald-500/20 text-emerald-400 border border-emerald-500/30 font-bold px-2.5 py-1 rounded-full text-[10px]">Entregado & Cobrado</span>;
|
||||
case 'cancelled':
|
||||
return <span className="bg-red-500/20 text-red-400 border border-red-500/30 font-bold px-2.5 py-1 rounded-full text-[10px]">Cancelado</span>;
|
||||
}
|
||||
};
|
||||
|
||||
const filteredOrders = orders.filter((o) => {
|
||||
const matchesSearch =
|
||||
o.customerName.toLowerCase().includes(search.toLowerCase()) ||
|
||||
o.customerPhone.includes(search) ||
|
||||
o.cityName.toLowerCase().includes(search.toLowerCase()) ||
|
||||
o.id.toLowerCase().includes(search.toLowerCase());
|
||||
|
||||
if (!matchesSearch) return false;
|
||||
if (statusFilter !== 'all' && o.status !== statusFilter) return false;
|
||||
return true;
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div>
|
||||
<h1 className="text-2xl font-black text-white flex items-center gap-2">
|
||||
<ShoppingBag className="w-6 h-6 text-cyan-400" />
|
||||
<span>Gestión de Pedidos & Separación con Proveedores ({orders.length})</span>
|
||||
</h1>
|
||||
<p className="text-xs text-slate-400 mt-1">
|
||||
Control logístico de pedidos contraentrega, cobro de anticipos de $10.000 COP y <strong>notificación inmediata de separación al proveedor</strong> vía WhatsApp.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Toolbar */}
|
||||
<div className="flex flex-col sm:flex-row items-stretch sm:items-center justify-between gap-3">
|
||||
<div className="flex items-center space-x-1.5 bg-slate-900 border border-slate-800 p-1 rounded-2xl overflow-x-auto">
|
||||
<button
|
||||
onClick={() => setStatusFilter('all')}
|
||||
className={`text-xs font-bold px-3 py-1.5 rounded-xl cursor-pointer transition-all ${
|
||||
statusFilter === 'all' ? 'bg-white text-slate-950 shadow' : 'text-slate-400 hover:text-white'
|
||||
}`}
|
||||
>
|
||||
Todos ({orders.length})
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setStatusFilter('pending')}
|
||||
className={`text-xs font-bold px-3 py-1.5 rounded-xl cursor-pointer transition-all ${
|
||||
statusFilter === 'pending' ? 'bg-amber-500/30 text-amber-300' : 'text-slate-400'
|
||||
}`}
|
||||
>
|
||||
Pendientes
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setStatusFilter('supplier_notified')}
|
||||
className={`text-xs font-bold px-3 py-1.5 rounded-xl cursor-pointer transition-all ${
|
||||
statusFilter === 'supplier_notified' ? 'bg-cyan-500/30 text-cyan-300' : 'text-slate-400'
|
||||
}`}
|
||||
>
|
||||
Proveedor Notificado
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setStatusFilter('dispatched')}
|
||||
className={`text-xs font-bold px-3 py-1.5 rounded-xl cursor-pointer transition-all ${
|
||||
statusFilter === 'dispatched' ? 'bg-purple-500/30 text-purple-300' : 'text-slate-400'
|
||||
}`}
|
||||
>
|
||||
En Ruta
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="max-w-xs w-full">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Buscar por cliente, ciudad o # orden..."
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
className="w-full bg-slate-900 border border-slate-800 rounded-xl px-3.5 py-2 text-xs text-white outline-hidden focus:border-cyan-400"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{filteredOrders.length === 0 ? (
|
||||
<div className="bg-slate-900 border border-slate-800 rounded-3xl p-12 text-center space-y-3">
|
||||
<div className="w-16 h-16 rounded-full bg-slate-950 text-slate-500 flex items-center justify-center mx-auto border border-slate-800">
|
||||
<ShoppingBag className="w-8 h-8 text-cyan-400" />
|
||||
</div>
|
||||
<h3 className="font-bold text-white text-base">No hay pedidos en esta sección</h3>
|
||||
<p className="text-xs text-slate-400 max-w-sm mx-auto">
|
||||
Cuando los clientes hagan un pedido en la tienda web o por WhatsApp, aparecerán aquí con el botón para separar inmediatamente con el proveedor correspondiente.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{filteredOrders.map((order) => (
|
||||
<div
|
||||
key={order.id}
|
||||
className="bg-slate-900 border border-slate-800 rounded-3xl p-5 space-y-4 shadow-xl"
|
||||
>
|
||||
{/* Top Order bar */}
|
||||
<div className="flex flex-wrap items-center justify-between gap-2 border-b border-slate-800 pb-3">
|
||||
<div className="flex items-center space-x-2">
|
||||
<span className="font-black text-white text-sm">Orden #{order.id}</span>
|
||||
<span className="text-xs text-slate-500">• {new Date(order.createdAt).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}</span>
|
||||
</div>
|
||||
{getStatusBadge(order.status)}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 text-xs">
|
||||
{/* Customer Details */}
|
||||
<div className="bg-slate-950/60 border border-slate-800/80 rounded-2xl p-3.5 space-y-1.5">
|
||||
<span className="text-[10px] text-cyan-400 font-bold uppercase block tracking-wider">
|
||||
Cliente & Entrega (24 Horas)
|
||||
</span>
|
||||
<p className="font-bold text-white text-sm">{order.customerName}</p>
|
||||
<p className="text-slate-300 flex items-center gap-1.5">
|
||||
<Phone className="w-3.5 h-3.5 text-cyan-400" /> {order.customerPhone}
|
||||
</p>
|
||||
<p className="text-slate-300 flex items-center gap-1.5">
|
||||
<MapPin className="w-3.5 h-3.5 text-cyan-400" /> {order.address} ({order.cityName})
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Items and Supplier Separation Action */}
|
||||
<div className="bg-slate-950/60 border border-slate-800/80 rounded-2xl p-3.5 space-y-2">
|
||||
<span className="text-[10px] text-slate-400 font-bold uppercase block tracking-wider">
|
||||
Zapatillas & Proveedor Asignado:
|
||||
</span>
|
||||
<div className="space-y-2.5">
|
||||
{order.items.map((it, idx) => {
|
||||
const suppName = it.supplierName || 'DIFUSION JOVIC STORE';
|
||||
return (
|
||||
<div key={idx} className="bg-slate-900 border border-slate-800 rounded-xl p-2.5 space-y-2">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div>
|
||||
<p className="font-bold text-white text-xs">{it.productName}</p>
|
||||
<p className="text-[11px] text-slate-400">
|
||||
Talla <strong className="text-cyan-400 font-bold">{it.size}</strong> • Cant: {it.quantity}
|
||||
</p>
|
||||
</div>
|
||||
<span className="text-[10px] bg-slate-950 text-slate-300 border border-slate-800 px-2 py-0.5 rounded font-mono">
|
||||
Prov: {suppName}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* 1-Click WhatsApp Separation Button */}
|
||||
<button
|
||||
onClick={() => handleNotifySupplier(order, it)}
|
||||
className="w-full bg-emerald-600/20 hover:bg-emerald-600/30 text-emerald-300 border border-emerald-500/30 font-bold text-xs py-1.5 px-2.5 rounded-lg flex items-center justify-center space-x-1.5 transition-all cursor-pointer shadow-xs"
|
||||
title="Enviar mensaje preformateado al WhatsApp del proveedor para separar este par"
|
||||
>
|
||||
<MessageCircle className="w-3.5 h-3.5 text-emerald-400" />
|
||||
<span>📲 Separar con {suppName} (WhatsApp)</span>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Payment Breakdown */}
|
||||
<div className="bg-slate-950/60 border border-slate-800/80 rounded-2xl p-3.5 space-y-2">
|
||||
<span className="text-[10px] text-slate-400 font-bold uppercase block tracking-wider">
|
||||
Cobro & Pagos Contraentrega
|
||||
</span>
|
||||
<div className="space-y-1 text-xs">
|
||||
<p className="text-slate-400 flex justify-between">
|
||||
<span>Total de la Orden:</span>
|
||||
<strong className="text-white font-mono">{formatCOP(order.totalAmount)}</strong>
|
||||
</p>
|
||||
{order.depositAmount > 0 ? (
|
||||
<>
|
||||
<p className="text-amber-300 flex justify-between">
|
||||
<span>Anticipo / Separación:</span>
|
||||
<strong className="font-mono">{formatCOP(order.depositAmount)}</strong>
|
||||
</p>
|
||||
<p className="text-emerald-400 font-bold flex justify-between pt-1 border-t border-slate-800">
|
||||
<span>Saldo al Mensajero:</span>
|
||||
<strong className="font-mono">{formatCOP(order.balanceDue)}</strong>
|
||||
</p>
|
||||
</>
|
||||
) : (
|
||||
<p className="text-emerald-400 font-bold pt-1 border-t border-slate-800">
|
||||
100% Contraentrega al recibir
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Order Status Action Buttons */}
|
||||
<div className="flex flex-wrap items-center justify-between gap-2 pt-2 border-t border-slate-800">
|
||||
<span className="text-[11px] text-slate-500">Método: {order.paymentMethodName}</span>
|
||||
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<button
|
||||
onClick={() => handleStatusChange(order.id, 'deposit_received')}
|
||||
className="bg-blue-600/20 hover:bg-blue-600/30 text-blue-300 text-xs font-bold px-3 py-1.5 rounded-xl cursor-pointer border border-blue-500/20"
|
||||
>
|
||||
Confirmar $10k
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleStatusChange(order.id, 'supplier_notified')}
|
||||
className="bg-cyan-600/20 hover:bg-cyan-600/30 text-cyan-300 text-xs font-bold px-3 py-1.5 rounded-xl cursor-pointer border border-cyan-500/20"
|
||||
>
|
||||
Marcar Proveedor Notificado
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleStatusChange(order.id, 'dispatched')}
|
||||
className="bg-purple-600/20 hover:bg-purple-600/30 text-purple-300 text-xs font-bold px-3 py-1.5 rounded-xl cursor-pointer border border-purple-500/20"
|
||||
>
|
||||
Despachar en Ruta
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleStatusChange(order.id, 'delivered')}
|
||||
className="bg-emerald-600/20 hover:bg-emerald-600/30 text-emerald-300 text-xs font-bold px-3 py-1.5 rounded-xl cursor-pointer border border-emerald-500/20"
|
||||
>
|
||||
Marcar Entregado
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
472
almeira-store-app/src/app/admin/productos/page.tsx
Normal file
@ -0,0 +1,472 @@
|
||||
'use client';
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Package, Plus, Eye, EyeOff, Check, X, Sparkles, Filter, Phone, MessageCircle, Truck } from 'lucide-react';
|
||||
import { Product, Supplier } from '@/types';
|
||||
import { INITIAL_PRODUCTS, INITIAL_SUPPLIERS } from '@/lib/data-service';
|
||||
import { formatCOP } from '@/lib/pricing-engine';
|
||||
|
||||
export default function AdminProductsPage() {
|
||||
const [products, setProducts] = useState<Product[]>(INITIAL_PRODUCTS);
|
||||
const [suppliers, setSuppliers] = useState<Supplier[]>(INITIAL_SUPPLIERS);
|
||||
const [search, setSearch] = useState('');
|
||||
const [statusFilter, setStatusFilter] = useState<'all' | 'active' | 'inactive'>('all');
|
||||
const [supplierFilter, setSupplierFilter] = useState<string>('all');
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
const [toastMessage, setToastMessage] = useState<string | null>(null);
|
||||
|
||||
// New product form
|
||||
const [name, setName] = useState('');
|
||||
const [brand, setBrand] = useState('Nike');
|
||||
const [selectedSupplierId, setSelectedSupplierId] = useState('sup-1');
|
||||
const [supplierPrice, setSupplierPrice] = useState('90000');
|
||||
const [retailPrice, setRetailPrice] = useState('125000');
|
||||
const [sizesInput, setSizesInput] = useState('37, 38, 39, 40, 41, 42, 43');
|
||||
const [imageUrl, setImageUrl] = useState(
|
||||
'https://images.unsplash.com/photo-1595950653106-6c9ebd614d3a?auto=format&fit=crop&w=800&q=80'
|
||||
);
|
||||
const [category, setCategory] = useState('Sneakers / Urbano');
|
||||
const [isActiveNew, setIsActiveNew] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
fetch('/api/products?all=true')
|
||||
.then((r) => r.json())
|
||||
.then((d) => d.success && setProducts(d.products))
|
||||
.catch(() => {});
|
||||
}, []);
|
||||
|
||||
const handleToggleProductStatus = async (product: Product) => {
|
||||
const newStatus = product.isActive === false ? true : false;
|
||||
|
||||
setProducts((prev) =>
|
||||
prev.map((p) => (p.id === product.id ? { ...p, isActive: newStatus } : p))
|
||||
);
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/products', {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
id: product.id,
|
||||
updates: { isActive: newStatus },
|
||||
}),
|
||||
});
|
||||
|
||||
const data = await res.json();
|
||||
if (data.success) {
|
||||
setToastMessage(
|
||||
`Zapatilla "${product.name}" ahora está ${newStatus ? '🟢 ACTIVA (Visible en la tienda)' : '⚪ INACTIVA (Oculta de la tienda)'}.`
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Error toggling status:', e);
|
||||
}
|
||||
};
|
||||
|
||||
const handleContactSupplier = (product: Product) => {
|
||||
const phone = product.supplierPhone || '573134848203';
|
||||
const suppName = product.supplierName || 'Proveedor Mayorista';
|
||||
const text = encodeURIComponent(
|
||||
`Hola ${suppName}! 👋 Consulta sobre disponibilidad del modelo *${product.name}* (Costo base: ${formatCOP(product.supplierPrice)}).`
|
||||
);
|
||||
window.open(`https://wa.me/${phone}?text=${text}`, '_blank');
|
||||
};
|
||||
|
||||
const handleCreateProduct = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
const sizes = sizesInput
|
||||
.split(',')
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
const chosenSupplier = suppliers.find((s) => s.id === selectedSupplierId) || suppliers[0];
|
||||
|
||||
const newProd: Product = {
|
||||
id: `snk-${Date.now()}`,
|
||||
name,
|
||||
brand,
|
||||
supplierPrice: parseInt(supplierPrice, 10) || 90000,
|
||||
retailPrice: parseInt(retailPrice, 10) || 125000,
|
||||
originalPrice: Math.round((parseInt(retailPrice, 10) || 125000) * 1.35),
|
||||
sizes,
|
||||
images: [imageUrl],
|
||||
description: 'Zapatillas de alta calidad garantizada para entrega inmediata.',
|
||||
category,
|
||||
inStock: true,
|
||||
isActive: isActiveNew,
|
||||
originBadge: 'Bodega Central',
|
||||
supplierId: chosenSupplier.id,
|
||||
supplierName: chosenSupplier.name,
|
||||
supplierPhone: chosenSupplier.phone,
|
||||
};
|
||||
|
||||
const res = await fetch('/api/products', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(newProd),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (data.success) {
|
||||
setProducts((prev) => [data.product, ...prev]);
|
||||
setIsModalOpen(false);
|
||||
setName('');
|
||||
setToastMessage(`✨ "${newProd.name}" registrada con proveedor [${chosenSupplier.name}].`);
|
||||
}
|
||||
};
|
||||
|
||||
const filtered = products.filter((p) => {
|
||||
const matchesSearch =
|
||||
p.name.toLowerCase().includes(search.toLowerCase()) ||
|
||||
p.brand.toLowerCase().includes(search.toLowerCase()) ||
|
||||
(p.supplierName && p.supplierName.toLowerCase().includes(search.toLowerCase()));
|
||||
|
||||
if (!matchesSearch) return false;
|
||||
if (statusFilter === 'active' && p.isActive === false) return false;
|
||||
if (statusFilter === 'inactive' && p.isActive !== false) return false;
|
||||
if (supplierFilter !== 'all' && p.supplierName !== supplierFilter) return false;
|
||||
return true;
|
||||
});
|
||||
|
||||
const activeCount = products.filter((p) => p.isActive !== false).length;
|
||||
const inactiveCount = products.filter((p) => p.isActive === false).length;
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
|
||||
<div>
|
||||
<h1 className="text-2xl font-black text-white flex items-center gap-2">
|
||||
<Package className="w-6 h-6 text-cyan-400" />
|
||||
<span>Catálogo General de Zapatillas & Proveedores ({products.length})</span>
|
||||
</h1>
|
||||
<p className="text-xs text-slate-400 mt-1">
|
||||
Visualiza cada modelo con su <strong>proveedor asignado</strong>, costo mayorista y activa/inactiva publicaciones para la tienda.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={() => setIsModalOpen(true)}
|
||||
className="bg-gradient-to-r from-cyan-500 to-blue-600 hover:from-cyan-400 hover:to-blue-500 text-white font-bold text-xs px-4 py-2.5 rounded-xl flex items-center space-x-1.5 shadow-[0_0_15px_rgba(6,182,212,0.3)] cursor-pointer"
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
<span>Agregar Zapatilla con Proveedor</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{toastMessage && (
|
||||
<div className="bg-cyan-950/80 border border-cyan-500 text-cyan-300 px-4 py-3 rounded-2xl text-xs flex items-center justify-between animate-in fade-in shadow-md">
|
||||
<span>{toastMessage}</span>
|
||||
<button onClick={() => setToastMessage(null)} className="font-bold text-white cursor-pointer ml-3">
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Filters & Search Toolbar */}
|
||||
<div className="flex flex-col md:flex-row items-stretch md:items-center justify-between gap-3">
|
||||
{/* Status filter pills */}
|
||||
<div className="flex items-center space-x-1.5 bg-slate-900 border border-slate-800 p-1 rounded-2xl overflow-x-auto">
|
||||
<button
|
||||
onClick={() => setStatusFilter('all')}
|
||||
className={`text-xs font-bold px-3 py-1.5 rounded-xl transition-all cursor-pointer ${
|
||||
statusFilter === 'all' ? 'bg-white text-slate-950 shadow' : 'text-slate-400 hover:text-white'
|
||||
}`}
|
||||
>
|
||||
Todos ({products.length})
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setStatusFilter('active')}
|
||||
className={`text-xs font-bold px-3 py-1.5 rounded-xl transition-all cursor-pointer flex items-center gap-1.5 ${
|
||||
statusFilter === 'active'
|
||||
? 'bg-emerald-500/30 border border-emerald-500/50 text-emerald-300 shadow'
|
||||
: 'text-slate-400 hover:text-emerald-400'
|
||||
}`}
|
||||
>
|
||||
<span className="w-2 h-2 rounded-full bg-emerald-400" />
|
||||
<span>Activos ({activeCount})</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setStatusFilter('inactive')}
|
||||
className={`text-xs font-bold px-3 py-1.5 rounded-xl transition-all cursor-pointer flex items-center gap-1.5 ${
|
||||
statusFilter === 'inactive'
|
||||
? 'bg-amber-500/30 border border-amber-500/50 text-amber-300 shadow'
|
||||
: 'text-slate-400 hover:text-amber-400'
|
||||
}`}
|
||||
>
|
||||
<span className="w-2 h-2 rounded-full bg-amber-400" />
|
||||
<span>Inactivos ({inactiveCount})</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Search */}
|
||||
<div className="max-w-xs w-full">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Filtrar por modelo, marca o proveedor..."
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
className="w-full bg-slate-900 border border-slate-800 rounded-xl px-3.5 py-2 text-xs text-white outline-hidden focus:border-cyan-400"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Products Table */}
|
||||
<div className="bg-slate-900 border border-slate-800 rounded-3xl overflow-hidden shadow-xl">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-left text-xs text-slate-300">
|
||||
<thead className="bg-slate-950 text-slate-400 font-bold uppercase text-[10px] border-b border-slate-800">
|
||||
<tr>
|
||||
<th className="py-3.5 px-4">Estado Publicación</th>
|
||||
<th className="py-3.5 px-4">Producto</th>
|
||||
<th className="py-3.5 px-4">Proveedor Asignado (Interno)</th>
|
||||
<th className="py-3.5 px-4">Marca</th>
|
||||
<th className="py-3.5 px-4">Costo Base</th>
|
||||
<th className="py-3.5 px-4">Precio Venta</th>
|
||||
<th className="py-3.5 px-4">Margen Ganancia</th>
|
||||
<th className="py-3.5 px-4">Tallas</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-slate-800/60">
|
||||
{filtered.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={8} className="text-center py-8 text-slate-500">
|
||||
No hay zapatillas que coincidan con el filtro seleccionado.
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
filtered.map((prod) => {
|
||||
const isActive = prod.isActive !== false;
|
||||
const margin = prod.retailPrice - prod.supplierPrice;
|
||||
const suppName = prod.supplierName || 'DIFUSION JOVIC STORE';
|
||||
const suppPhone = prod.supplierPhone || '573134848203';
|
||||
|
||||
return (
|
||||
<tr
|
||||
key={prod.id}
|
||||
className={`hover:bg-slate-800/50 transition-colors ${
|
||||
!isActive ? 'opacity-65 bg-slate-950/40' : ''
|
||||
}`}
|
||||
>
|
||||
{/* Estado Toggle Button */}
|
||||
<td className="py-3.5 px-4">
|
||||
<button
|
||||
onClick={() => handleToggleProductStatus(prod)}
|
||||
className={`text-[11px] font-bold px-3 py-1.5 rounded-xl border flex items-center gap-1.5 transition-all cursor-pointer ${
|
||||
isActive
|
||||
? 'bg-emerald-500/20 border-emerald-500/40 text-emerald-300 hover:bg-emerald-500/30'
|
||||
: 'bg-slate-950 border-slate-700 text-slate-400 hover:text-white'
|
||||
}`}
|
||||
title="Haz clic para activar o desactivar esta publicación en la tienda"
|
||||
>
|
||||
{isActive ? (
|
||||
<>
|
||||
<Eye className="w-3.5 h-3.5 text-emerald-400" />
|
||||
<span>Activo</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<EyeOff className="w-3.5 h-3.5 text-slate-400" />
|
||||
<span>Inactivo (Oculto)</span>
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</td>
|
||||
|
||||
{/* Product Name & Image */}
|
||||
<td className="py-3.5 px-4 flex items-center space-x-3">
|
||||
<img
|
||||
src={prod.images[0]}
|
||||
alt=""
|
||||
className="w-12 h-12 rounded-xl object-cover bg-slate-950 border border-slate-800 shrink-0"
|
||||
/>
|
||||
<div>
|
||||
<p className="font-bold text-white text-xs leading-snug">{prod.name}</p>
|
||||
<span className="text-[10px] text-slate-400">{prod.category}</span>
|
||||
</div>
|
||||
</td>
|
||||
|
||||
{/* Supplier Column */}
|
||||
<td className="py-3.5 px-4">
|
||||
<div className="space-y-1">
|
||||
<span className="bg-slate-950 text-cyan-300 font-bold px-2 py-0.5 rounded border border-slate-800 text-[11px] block w-fit">
|
||||
🏢 {suppName}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => handleContactSupplier(prod)}
|
||||
className="text-[10px] text-emerald-400 hover:text-emerald-300 flex items-center gap-1 font-semibold cursor-pointer"
|
||||
>
|
||||
<MessageCircle className="w-3 h-3" />
|
||||
<span>WhatsApp ({suppPhone})</span>
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
|
||||
<td className="py-3.5 px-4 font-bold text-cyan-400">{prod.brand}</td>
|
||||
<td className="py-3.5 px-4 font-mono">{formatCOP(prod.supplierPrice)}</td>
|
||||
<td className="py-3.5 px-4 font-mono font-bold text-white">{formatCOP(prod.retailPrice)}</td>
|
||||
|
||||
<td className="py-3.5 px-4">
|
||||
<span className="bg-cyan-500/20 text-cyan-300 border border-cyan-500/30 font-bold px-2 py-0.5 rounded font-mono">
|
||||
+{formatCOP(margin)}
|
||||
</span>
|
||||
</td>
|
||||
|
||||
<td className="py-3.5 px-4">
|
||||
<div className="flex gap-1 flex-wrap max-w-xs">
|
||||
{prod.sizes.map((s) => (
|
||||
<span key={s} className="bg-slate-950 text-slate-300 text-[10px] px-1.5 py-0.5 rounded border border-slate-800 font-bold">
|
||||
{s}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Modal Add Product */}
|
||||
{isModalOpen && (
|
||||
<div className="fixed inset-0 z-50 bg-black/80 backdrop-blur-xs flex items-center justify-center p-4 animate-in fade-in">
|
||||
<div className="bg-slate-900 border border-slate-800 rounded-3xl max-w-lg w-full p-6 space-y-4 shadow-2xl">
|
||||
<h3 className="font-bold text-white text-base">Registrar Nueva Zapatilla</h3>
|
||||
|
||||
<form onSubmit={handleCreateProduct} className="space-y-3 text-xs">
|
||||
<div>
|
||||
<label className="block text-slate-400 font-bold mb-1">Nombre del Modelo *</label>
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
placeholder="Ej. Nike Air Jordan 1 High Chicago"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
className="w-full bg-slate-950 border border-slate-800 rounded-xl px-3 py-2 text-white outline-hidden"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Supplier Selection */}
|
||||
<div>
|
||||
<label className="block text-cyan-400 font-bold mb-1">Proveedor Asignado (Interno) *</label>
|
||||
<select
|
||||
value={selectedSupplierId}
|
||||
onChange={(e) => setSelectedSupplierId(e.target.value)}
|
||||
className="w-full bg-slate-950 border border-slate-800 rounded-xl px-3 py-2 text-white outline-hidden font-semibold"
|
||||
>
|
||||
{suppliers.map((s) => (
|
||||
<option key={s.id} value={s.id}>
|
||||
{s.name} — WhatsApp: {s.phone}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<p className="text-[10px] text-slate-500 mt-0.5">
|
||||
Este proveedor recibirá la notificación para separar el par cuando se haga un pedido.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="block text-slate-400 font-bold mb-1">Marca</label>
|
||||
<select
|
||||
value={brand}
|
||||
onChange={(e) => setBrand(e.target.value)}
|
||||
className="w-full bg-slate-950 border border-slate-800 rounded-xl px-3 py-2 text-white outline-hidden"
|
||||
>
|
||||
<option value="Nike">Nike</option>
|
||||
<option value="Jordan">Jordan</option>
|
||||
<option value="Adidas">Adidas</option>
|
||||
<option value="New Balance">New Balance</option>
|
||||
<option value="Puma">Puma</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-slate-400 font-bold mb-1">Categoría</label>
|
||||
<input
|
||||
type="text"
|
||||
value={category}
|
||||
onChange={(e) => setCategory(e.target.value)}
|
||||
className="w-full bg-slate-950 border border-slate-800 rounded-xl px-3 py-2 text-white outline-hidden"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="block text-slate-400 font-bold mb-1">Costo Base Mayorista (COP)</label>
|
||||
<input
|
||||
type="number"
|
||||
value={supplierPrice}
|
||||
onChange={(e) => setSupplierPrice(e.target.value)}
|
||||
className="w-full bg-slate-950 border border-slate-800 rounded-xl px-3 py-2 text-white outline-hidden"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-slate-400 font-bold mb-1">Precio Venta Sugerido (COP)</label>
|
||||
<input
|
||||
type="number"
|
||||
value={retailPrice}
|
||||
onChange={(e) => setRetailPrice(e.target.value)}
|
||||
className="w-full bg-slate-950 border border-slate-800 rounded-xl px-3 py-2 text-white outline-hidden"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-slate-400 font-bold mb-1">Tallas Disponibles (separadas por coma)</label>
|
||||
<input
|
||||
type="text"
|
||||
value={sizesInput}
|
||||
onChange={(e) => setSizesInput(e.target.value)}
|
||||
className="w-full bg-slate-950 border border-slate-800 rounded-xl px-3 py-2 text-white outline-hidden"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-slate-400 font-bold mb-1">URL de Imagen Principal</label>
|
||||
<input
|
||||
type="url"
|
||||
value={imageUrl}
|
||||
onChange={(e) => setImageUrl(e.target.value)}
|
||||
className="w-full bg-slate-950 border border-slate-800 rounded-xl px-3 py-2 text-white outline-hidden"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="pt-2">
|
||||
<label className="flex items-center space-x-2 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={isActiveNew}
|
||||
onChange={(e) => setIsActiveNew(e.target.checked)}
|
||||
className="rounded text-cyan-500 focus:ring-0"
|
||||
/>
|
||||
<span className="text-slate-200 font-bold">Publicar inmediatamente en la tienda (Activo)</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end space-x-2 pt-3 border-t border-slate-800">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsModalOpen(false)}
|
||||
className="bg-slate-800 hover:bg-slate-700 text-slate-300 font-bold py-2 px-4 rounded-xl cursor-pointer"
|
||||
>
|
||||
Cancelar
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
className="bg-gradient-to-r from-cyan-500 to-blue-600 hover:from-cyan-400 hover:to-blue-500 text-white font-bold py-2 px-4 rounded-xl shadow-md cursor-pointer"
|
||||
>
|
||||
Guardar Zapatilla
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
476
almeira-store-app/src/app/admin/usuarios/page.tsx
Normal file
@ -0,0 +1,476 @@
|
||||
'use client';
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Users, UserPlus, Shield, ShieldCheck, UserCheck, MessageCircle, Mail, Phone, MapPin, Eye, EyeOff, Trash2, Edit, Check, X } from 'lucide-react';
|
||||
import { User } from '@/types';
|
||||
|
||||
export default function AdminUsersPage() {
|
||||
const [users, setUsers] = useState<User[]>([]);
|
||||
const [search, setSearch] = useState('');
|
||||
const [roleFilter, setRoleFilter] = useState<'all' | 'admin' | 'operator' | 'customer'>('all');
|
||||
const [statusFilter, setStatusFilter] = useState<'all' | 'active' | 'inactive'>('all');
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
const [toastMessage, setToastMessage] = useState<string | null>(null);
|
||||
|
||||
// New user modal form
|
||||
const [name, setName] = useState('');
|
||||
const [email, setEmail] = useState('');
|
||||
const [phone, setPhone] = useState('');
|
||||
const [city, setCity] = useState('Cali');
|
||||
const [address, setAddress] = useState('');
|
||||
const [role, setRole] = useState<'admin' | 'operator' | 'customer'>('operator');
|
||||
|
||||
useEffect(() => {
|
||||
fetchUsers();
|
||||
}, []);
|
||||
|
||||
const fetchUsers = async () => {
|
||||
try {
|
||||
const res = await fetch('/api/users');
|
||||
const data = await res.json();
|
||||
if (data.success) {
|
||||
setUsers(data.users);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
};
|
||||
|
||||
const handleToggleStatus = async (user: User) => {
|
||||
const newStatus = !user.isActive;
|
||||
setUsers((prev) => prev.map((u) => (u.id === user.id ? { ...u, isActive: newStatus } : u)));
|
||||
|
||||
try {
|
||||
await fetch('/api/users', {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ id: user.id, updates: { isActive: newStatus } }),
|
||||
});
|
||||
setToastMessage(`Estado de ${user.name} actualizado a ${newStatus ? '🟢 ACTIVO' : '⚪ INACTIVO'}.`);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
};
|
||||
|
||||
const handleChangeRole = async (user: User, newRole: 'admin' | 'operator' | 'customer') => {
|
||||
setUsers((prev) => prev.map((u) => (u.id === user.id ? { ...u, role: newRole } : u)));
|
||||
|
||||
try {
|
||||
await fetch('/api/users', {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ id: user.id, updates: { role: newRole } }),
|
||||
});
|
||||
setToastMessage(`Rol de ${user.name} actualizado a [${newRole.toUpperCase()}].`);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteUser = async (id: string, name: string) => {
|
||||
if (!confirm(`¿Estás seguro de eliminar al usuario ${name}?`)) return;
|
||||
|
||||
setUsers((prev) => prev.filter((u) => u.id !== id));
|
||||
try {
|
||||
await fetch(`/api/users?id=${id}`, { method: 'DELETE' });
|
||||
setToastMessage(`Usuario ${name} eliminado.`);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCreateUser = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
try {
|
||||
const res = await fetch('/api/users', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name, email, phone, city, address, role }),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (data.success) {
|
||||
setUsers((prev) => [data.user, ...prev]);
|
||||
setIsModalOpen(false);
|
||||
setName('');
|
||||
setEmail('');
|
||||
setPhone('');
|
||||
setAddress('');
|
||||
setToastMessage(`✨ Usuario "${data.user.name}" registrado con éxito.`);
|
||||
} else {
|
||||
alert(data.error);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
};
|
||||
|
||||
const handleWhatsApp = (user: User) => {
|
||||
const cleanPhone = user.phone.startsWith('57') ? user.phone : `57${user.phone}`;
|
||||
const text = encodeURIComponent(`Hola ${user.name}! Te escribimos del equipo de Almeira Store 👋`);
|
||||
window.open(`https://wa.me/${cleanPhone}?text=${text}`, '_blank');
|
||||
};
|
||||
|
||||
const filtered = users.filter((u) => {
|
||||
const matchSearch =
|
||||
u.name.toLowerCase().includes(search.toLowerCase()) ||
|
||||
u.email.toLowerCase().includes(search.toLowerCase()) ||
|
||||
u.phone.includes(search) ||
|
||||
(u.city && u.city.toLowerCase().includes(search.toLowerCase()));
|
||||
|
||||
if (!matchSearch) return false;
|
||||
if (roleFilter !== 'all' && u.role !== roleFilter) return false;
|
||||
if (statusFilter === 'active' && !u.isActive) return false;
|
||||
if (statusFilter === 'inactive' && u.isActive) return false;
|
||||
return true;
|
||||
});
|
||||
|
||||
const adminsCount = users.filter((u) => u.role === 'admin').length;
|
||||
const operatorsCount = users.filter((u) => u.role === 'operator').length;
|
||||
const customersCount = users.filter((u) => u.role === 'customer').length;
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
|
||||
<div>
|
||||
<h1 className="text-2xl font-black text-white flex items-center gap-2">
|
||||
<Users className="w-6 h-6 text-cyan-400" />
|
||||
<span>Control de Usuarios, Roles & Registro ({users.length})</span>
|
||||
</h1>
|
||||
<p className="text-xs text-slate-400 mt-1">
|
||||
Administra los accesos de administradores, operadores de logística y clientes registrados de la tienda.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={() => setIsModalOpen(true)}
|
||||
className="bg-gradient-to-r from-cyan-500 to-blue-600 hover:from-cyan-400 hover:to-blue-500 text-white font-bold text-xs px-4 py-2.5 rounded-xl flex items-center space-x-1.5 shadow-[0_0_15px_rgba(6,182,212,0.3)] cursor-pointer"
|
||||
>
|
||||
<UserPlus className="w-4 h-4" />
|
||||
<span>Registrar Usuario u Operador</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{toastMessage && (
|
||||
<div className="bg-cyan-950/80 border border-cyan-500 text-cyan-300 px-4 py-3 rounded-2xl text-xs flex items-center justify-between shadow-md animate-in fade-in">
|
||||
<span>{toastMessage}</span>
|
||||
<button onClick={() => setToastMessage(null)} className="font-bold text-white cursor-pointer ml-3">
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* KPI Stats Cards */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
|
||||
<div className="bg-slate-900 border border-slate-800 rounded-2xl p-4 flex items-center space-x-3 shadow-lg">
|
||||
<div className="w-10 h-10 rounded-xl bg-purple-500/20 border border-purple-500/30 flex items-center justify-center text-purple-400">
|
||||
<Shield className="w-5 h-5" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-[11px] text-slate-400 font-semibold">Administradores</p>
|
||||
<p className="text-xl font-black text-white">{adminsCount}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-slate-900 border border-slate-800 rounded-2xl p-4 flex items-center space-x-3 shadow-lg">
|
||||
<div className="w-10 h-10 rounded-xl bg-cyan-500/20 border border-cyan-500/30 flex items-center justify-center text-cyan-400">
|
||||
<ShieldCheck className="w-5 h-5" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-[11px] text-slate-400 font-semibold">Operadores Logísticos</p>
|
||||
<p className="text-xl font-black text-white">{operatorsCount}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-slate-900 border border-slate-800 rounded-2xl p-4 flex items-center space-x-3 shadow-lg">
|
||||
<div className="w-10 h-10 rounded-xl bg-emerald-500/20 border border-emerald-500/30 flex items-center justify-center text-emerald-400">
|
||||
<UserCheck className="w-5 h-5" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-[11px] text-slate-400 font-semibold">Clientes Registrados</p>
|
||||
<p className="text-xl font-black text-white">{customersCount}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Filters Toolbar */}
|
||||
<div className="flex flex-col md:flex-row items-stretch md:items-center justify-between gap-3">
|
||||
{/* Role Pills */}
|
||||
<div className="flex items-center space-x-1.5 bg-slate-900 border border-slate-800 p-1 rounded-2xl overflow-x-auto">
|
||||
<button
|
||||
onClick={() => setRoleFilter('all')}
|
||||
className={`text-xs font-bold px-3 py-1.5 rounded-xl transition-all cursor-pointer ${
|
||||
roleFilter === 'all' ? 'bg-white text-slate-950 shadow' : 'text-slate-400 hover:text-white'
|
||||
}`}
|
||||
>
|
||||
Todos ({users.length})
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setRoleFilter('admin')}
|
||||
className={`text-xs font-bold px-3 py-1.5 rounded-xl transition-all cursor-pointer ${
|
||||
roleFilter === 'admin' ? 'bg-purple-500/30 text-purple-300 border border-purple-500/50 shadow' : 'text-slate-400 hover:text-purple-300'
|
||||
}`}
|
||||
>
|
||||
Admins ({adminsCount})
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setRoleFilter('operator')}
|
||||
className={`text-xs font-bold px-3 py-1.5 rounded-xl transition-all cursor-pointer ${
|
||||
roleFilter === 'operator' ? 'bg-cyan-500/30 text-cyan-300 border border-cyan-500/50 shadow' : 'text-slate-400 hover:text-cyan-300'
|
||||
}`}
|
||||
>
|
||||
Operadores ({operatorsCount})
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setRoleFilter('customer')}
|
||||
className={`text-xs font-bold px-3 py-1.5 rounded-xl transition-all cursor-pointer ${
|
||||
roleFilter === 'customer' ? 'bg-emerald-500/30 text-emerald-300 border border-emerald-500/50 shadow' : 'text-slate-400 hover:text-emerald-300'
|
||||
}`}
|
||||
>
|
||||
Clientes ({customersCount})
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Search */}
|
||||
<div className="max-w-xs w-full">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Buscar por nombre, correo o celular..."
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
className="w-full bg-slate-900 border border-slate-800 rounded-xl px-3.5 py-2 text-xs text-white outline-hidden focus:border-cyan-400"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Users Table */}
|
||||
<div className="bg-slate-900 border border-slate-800 rounded-3xl overflow-hidden shadow-xl">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-left text-xs text-slate-300">
|
||||
<thead className="bg-slate-950 text-slate-400 font-bold uppercase text-[10px] border-b border-slate-800">
|
||||
<tr>
|
||||
<th className="py-3.5 px-4">Estado</th>
|
||||
<th className="py-3.5 px-4">Usuario</th>
|
||||
<th className="py-3.5 px-4">Contacto Directo</th>
|
||||
<th className="py-3.5 px-4">Ciudad / Dirección</th>
|
||||
<th className="py-3.5 px-4">Rol & Permisos</th>
|
||||
<th className="py-3.5 px-4">Último Acceso</th>
|
||||
<th className="py-3.5 px-4 text-right">Acciones</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-slate-800/60">
|
||||
{filtered.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={7} className="text-center py-8 text-slate-500">
|
||||
No se encontraron usuarios con el filtro seleccionado.
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
filtered.map((user) => {
|
||||
return (
|
||||
<tr
|
||||
key={user.id}
|
||||
className={`hover:bg-slate-800/50 transition-colors ${
|
||||
!user.isActive ? 'opacity-65 bg-slate-950/40' : ''
|
||||
}`}
|
||||
>
|
||||
{/* Active Status Toggle */}
|
||||
<td className="py-3.5 px-4">
|
||||
<button
|
||||
onClick={() => handleToggleStatus(user)}
|
||||
className={`text-[11px] font-bold px-2.5 py-1 rounded-xl border flex items-center gap-1.5 transition-all cursor-pointer ${
|
||||
user.isActive
|
||||
? 'bg-emerald-500/20 border-emerald-500/40 text-emerald-300 hover:bg-emerald-500/30'
|
||||
: 'bg-slate-950 border-slate-700 text-slate-400 hover:text-white'
|
||||
}`}
|
||||
>
|
||||
{user.isActive ? (
|
||||
<>
|
||||
<Eye className="w-3.5 h-3.5 text-emerald-400" />
|
||||
<span>Activo</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<EyeOff className="w-3.5 h-3.5 text-slate-400" />
|
||||
<span>Inactivo</span>
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</td>
|
||||
|
||||
{/* User Avatar & Name */}
|
||||
<td className="py-3.5 px-4 flex items-center space-x-3">
|
||||
<div className="w-9 h-9 rounded-xl bg-gradient-to-br from-cyan-500 to-blue-600 flex items-center justify-center text-white font-bold text-xs shadow-sm shrink-0">
|
||||
{user.name.charAt(0).toUpperCase()}
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-bold text-white text-xs">{user.name}</p>
|
||||
<span className="text-[10px] text-slate-400">{user.email}</span>
|
||||
</div>
|
||||
</td>
|
||||
|
||||
{/* Contact */}
|
||||
<td className="py-3.5 px-4">
|
||||
<div className="space-y-1">
|
||||
<button
|
||||
onClick={() => handleWhatsApp(user)}
|
||||
className="text-[11px] text-emerald-400 hover:text-emerald-300 font-bold flex items-center gap-1 cursor-pointer"
|
||||
>
|
||||
<MessageCircle className="w-3.5 h-3.5" />
|
||||
<span>{user.phone}</span>
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
|
||||
{/* City */}
|
||||
<td className="py-3.5 px-4">
|
||||
<div className="text-[11px]">
|
||||
<span className="text-white font-medium">{user.city || 'Cali'}</span>
|
||||
{user.address && <p className="text-[10px] text-slate-400 truncate max-w-xs">{user.address}</p>}
|
||||
</div>
|
||||
</td>
|
||||
|
||||
{/* Role Dropdown */}
|
||||
<td className="py-3.5 px-4">
|
||||
<select
|
||||
value={user.role}
|
||||
onChange={(e) => handleChangeRole(user, e.target.value as any)}
|
||||
className={`text-[11px] font-bold px-2 py-1 rounded-lg border outline-hidden cursor-pointer ${
|
||||
user.role === 'admin'
|
||||
? 'bg-purple-500/20 border-purple-500/40 text-purple-300'
|
||||
: user.role === 'operator'
|
||||
? 'bg-cyan-500/20 border-cyan-500/40 text-cyan-300'
|
||||
: 'bg-emerald-500/20 border-emerald-500/40 text-emerald-300'
|
||||
}`}
|
||||
>
|
||||
<option value="admin" className="bg-slate-900 text-white">👑 Admin Total</option>
|
||||
<option value="operator" className="bg-slate-900 text-white">📦 Operador Logística</option>
|
||||
<option value="customer" className="bg-slate-900 text-white">🛍️ Cliente Comprador</option>
|
||||
</select>
|
||||
</td>
|
||||
|
||||
{/* Last Login */}
|
||||
<td className="py-3.5 px-4 text-slate-400 text-[11px] font-mono">
|
||||
{user.lastLogin || user.createdAt}
|
||||
</td>
|
||||
|
||||
{/* Actions */}
|
||||
<td className="py-3.5 px-4 text-right">
|
||||
<button
|
||||
onClick={() => handleDeleteUser(user.id, user.name)}
|
||||
className="text-red-400 hover:text-red-300 p-1.5 rounded-lg hover:bg-red-500/10 cursor-pointer transition-colors"
|
||||
title="Eliminar usuario"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Modal Create User */}
|
||||
{isModalOpen && (
|
||||
<div className="fixed inset-0 z-50 bg-black/80 backdrop-blur-xs flex items-center justify-center p-4 animate-in fade-in">
|
||||
<div className="bg-slate-900 border border-slate-800 rounded-3xl max-w-md w-full p-6 space-y-4 shadow-2xl">
|
||||
<h3 className="font-bold text-white text-base">Registrar Nuevo Usuario / Operador</h3>
|
||||
|
||||
<form onSubmit={handleCreateUser} className="space-y-3 text-xs">
|
||||
<div>
|
||||
<label className="block text-slate-400 font-bold mb-1">Nombre Completo *</label>
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
placeholder="ej: Juan Pérez"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
className="w-full bg-slate-950 border border-slate-800 rounded-xl px-3 py-2 text-white outline-hidden"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-slate-400 font-bold mb-1">Correo Electrónico *</label>
|
||||
<input
|
||||
type="email"
|
||||
required
|
||||
placeholder="ej: juan@gmail.com"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
className="w-full bg-slate-950 border border-slate-800 rounded-xl px-3 py-2 text-white outline-hidden"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="block text-slate-400 font-bold mb-1">Celular / WhatsApp *</label>
|
||||
<input
|
||||
type="tel"
|
||||
required
|
||||
placeholder="ej: 3134848203"
|
||||
value={phone}
|
||||
onChange={(e) => setPhone(e.target.value)}
|
||||
className="w-full bg-slate-950 border border-slate-800 rounded-xl px-3 py-2 text-white outline-hidden"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-slate-400 font-bold mb-1">Ciudad Principal</label>
|
||||
<input
|
||||
type="text"
|
||||
value={city}
|
||||
onChange={(e) => setCity(e.target.value)}
|
||||
className="w-full bg-slate-950 border border-slate-800 rounded-xl px-3 py-2 text-white outline-hidden"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-slate-400 font-bold mb-1">Dirección (Opcional)</label>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="ej: Calle 5 # 34-12 Apto 402"
|
||||
value={address}
|
||||
onChange={(e) => setAddress(e.target.value)}
|
||||
className="w-full bg-slate-950 border border-slate-800 rounded-xl px-3 py-2 text-white outline-hidden"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-cyan-400 font-bold mb-1">Rol en el Sistema *</label>
|
||||
<select
|
||||
value={role}
|
||||
onChange={(e) => setRole(e.target.value as any)}
|
||||
className="w-full bg-slate-950 border border-slate-800 rounded-xl px-3 py-2 text-white outline-hidden font-bold"
|
||||
>
|
||||
<option value="operator">📦 Operador de Despachos & Logística</option>
|
||||
<option value="admin">👑 Administrador Total</option>
|
||||
<option value="customer">🛍️ Cliente Comprador</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end space-x-2 pt-3 border-t border-slate-800">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsModalOpen(false)}
|
||||
className="bg-slate-800 hover:bg-slate-700 text-slate-300 font-bold py-2 px-4 rounded-xl cursor-pointer"
|
||||
>
|
||||
Cancelar
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
className="bg-gradient-to-r from-cyan-500 to-blue-600 hover:from-cyan-400 hover:to-blue-500 text-white font-bold py-2 px-4 rounded-xl shadow-md cursor-pointer"
|
||||
>
|
||||
Registrar Usuario
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
31
almeira-store-app/src/app/api/auth/login/route.ts
Normal file
@ -0,0 +1,31 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { dataService } from '@/lib/data-service';
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const { identifier } = await request.json(); // email o teléfono
|
||||
if (!identifier) {
|
||||
return NextResponse.json({ success: false, error: 'Ingresa tu correo o celular' }, { status: 400 });
|
||||
}
|
||||
const user = dataService.authenticate(identifier);
|
||||
if (!user) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'Usuario no encontrado. Verifica tu correo o celular.' },
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
if (!user.isActive) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'Esta cuenta se encuentra inactiva. Contacta a soporte.' },
|
||||
{ status: 403 }
|
||||
);
|
||||
}
|
||||
|
||||
// Actualizar lastLogin
|
||||
dataService.updateUser(user.id, { lastLogin: 'Justo ahora' });
|
||||
|
||||
return NextResponse.json({ success: true, user });
|
||||
} catch (error: any) {
|
||||
return NextResponse.json({ success: false, error: error.message }, { status: 400 });
|
||||
}
|
||||
}
|
||||
42
almeira-store-app/src/app/api/auth/register/route.ts
Normal file
@ -0,0 +1,42 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { dataService } from '@/lib/data-service';
|
||||
import { User } from '@/types';
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const { name, email, phone, city, address } = await request.json();
|
||||
if (!name || !email || !phone) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'Nombre, correo y celular son obligatorios' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const existing = dataService.authenticate(email) || dataService.authenticate(phone);
|
||||
if (existing) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'Ya existe una cuenta con este correo o celular' },
|
||||
{ status: 409 }
|
||||
);
|
||||
}
|
||||
|
||||
const newUser: User = {
|
||||
id: `usr-${Date.now()}`,
|
||||
name,
|
||||
email,
|
||||
phone,
|
||||
city: city || 'Cali',
|
||||
address: address || '',
|
||||
role: 'customer',
|
||||
isActive: true,
|
||||
createdAt: new Date().toISOString().split('T')[0],
|
||||
totalOrders: 0,
|
||||
lastLogin: 'Justo ahora',
|
||||
};
|
||||
|
||||
const saved = dataService.addUser(newUser);
|
||||
return NextResponse.json({ success: true, user: saved });
|
||||
} catch (error: any) {
|
||||
return NextResponse.json({ success: false, error: error.message }, { status: 400 });
|
||||
}
|
||||
}
|
||||
27
almeira-store-app/src/app/api/cities/route.ts
Normal file
@ -0,0 +1,27 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { dataService } from '@/lib/data-service';
|
||||
|
||||
export async function GET() {
|
||||
const cities = dataService.getCities();
|
||||
return NextResponse.json({ success: true, cities });
|
||||
}
|
||||
|
||||
export async function PUT(request: Request) {
|
||||
try {
|
||||
const { id, updates } = await request.json();
|
||||
const updated = dataService.updateCity(id, updates);
|
||||
return NextResponse.json({ success: true, city: updated });
|
||||
} catch (error: any) {
|
||||
return NextResponse.json({ success: false, error: error.message }, { status: 400 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const body = await request.json();
|
||||
const created = dataService.addCity(body);
|
||||
return NextResponse.json({ success: true, city: created });
|
||||
} catch (error: any) {
|
||||
return NextResponse.json({ success: false, error: error.message }, { status: 400 });
|
||||
}
|
||||
}
|
||||
30
almeira-store-app/src/app/api/orders/route.ts
Normal file
@ -0,0 +1,30 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { dataService } from '@/lib/data-service';
|
||||
import { sendWhatsAppMessage, buildOrderConfirmationMessage } from '@/lib/evolution-api';
|
||||
|
||||
export async function GET() {
|
||||
const orders = dataService.getOrders();
|
||||
return NextResponse.json({ success: true, orders });
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const body = await request.json();
|
||||
const newOrder = dataService.createOrder(body);
|
||||
|
||||
// Enviar confirmación por WhatsApp en background si tiene teléfono válido
|
||||
if (newOrder.customerPhone) {
|
||||
try {
|
||||
const msg = buildOrderConfirmationMessage(newOrder);
|
||||
// Intentar enviar WhatsApp
|
||||
await sendWhatsAppMessage(newOrder.customerPhone, msg);
|
||||
} catch (waErr) {
|
||||
console.warn('No se pudo enviar WhatsApp automático:', waErr);
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json({ success: true, order: newOrder });
|
||||
} catch (error: any) {
|
||||
return NextResponse.json({ success: false, error: error.message }, { status: 400 });
|
||||
}
|
||||
}
|
||||
17
almeira-store-app/src/app/api/payment-methods/route.ts
Normal file
@ -0,0 +1,17 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { dataService } from '@/lib/data-service';
|
||||
|
||||
export async function GET() {
|
||||
const methods = dataService.getPaymentMethods();
|
||||
return NextResponse.json({ success: true, paymentMethods: methods });
|
||||
}
|
||||
|
||||
export async function PUT(request: Request) {
|
||||
try {
|
||||
const { id, updates } = await request.json();
|
||||
const updated = dataService.updatePaymentMethod(id, updates);
|
||||
return NextResponse.json({ success: true, paymentMethod: updated });
|
||||
} catch (error: any) {
|
||||
return NextResponse.json({ success: false, error: error.message }, { status: 400 });
|
||||
}
|
||||
}
|
||||
47
almeira-store-app/src/app/api/products/route.ts
Normal file
@ -0,0 +1,47 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { dataService } from '@/lib/data-service';
|
||||
|
||||
export async function GET(request: Request) {
|
||||
const { searchParams } = new URL(request.url);
|
||||
const includeAll = searchParams.get('all') === 'true';
|
||||
|
||||
let products = dataService.getProducts();
|
||||
if (!includeAll) {
|
||||
// Para la tienda pública, solo mostrar productos activos (isActive !== false)
|
||||
products = products.filter((p) => p.isActive !== false);
|
||||
}
|
||||
|
||||
return NextResponse.json({ success: true, products });
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const body = await request.json();
|
||||
const newProduct = dataService.addProduct({
|
||||
...body,
|
||||
isActive: body.isActive !== undefined ? body.isActive : true,
|
||||
});
|
||||
return NextResponse.json({ success: true, product: newProduct });
|
||||
} catch (error: any) {
|
||||
return NextResponse.json({ success: false, error: error.message }, { status: 400 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function PUT(request: Request) {
|
||||
try {
|
||||
const body = await request.json();
|
||||
const { id, updates } = body;
|
||||
if (!id) {
|
||||
return NextResponse.json({ success: false, error: 'ID de producto requerido' }, { status: 400 });
|
||||
}
|
||||
|
||||
const updated = dataService.updateProduct(id, updates);
|
||||
if (!updated) {
|
||||
return NextResponse.json({ success: false, error: 'Producto no encontrado' }, { status: 404 });
|
||||
}
|
||||
|
||||
return NextResponse.json({ success: true, product: updated });
|
||||
} catch (error: any) {
|
||||
return NextResponse.json({ success: false, error: error.message }, { status: 400 });
|
||||
}
|
||||
}
|
||||
12
almeira-store-app/src/app/api/publish/facebook/route.ts
Normal file
@ -0,0 +1,12 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { publishToFacebookPage } from '@/lib/facebook-publisher';
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const body = await request.json();
|
||||
const result = await publishToFacebookPage(body);
|
||||
return NextResponse.json(result);
|
||||
} catch (error: any) {
|
||||
return NextResponse.json({ success: false, error: error.message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
17
almeira-store-app/src/app/api/settings/route.ts
Normal file
@ -0,0 +1,17 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { dataService } from '@/lib/data-service';
|
||||
|
||||
export async function GET() {
|
||||
const settings = dataService.getSettings();
|
||||
return NextResponse.json({ success: true, settings });
|
||||
}
|
||||
|
||||
export async function PUT(request: Request) {
|
||||
try {
|
||||
const body = await request.json();
|
||||
const updated = dataService.updateSettings(body);
|
||||
return NextResponse.json({ success: true, settings: updated });
|
||||
} catch (error: any) {
|
||||
return NextResponse.json({ success: false, error: error.message }, { status: 400 });
|
||||
}
|
||||
}
|
||||
57
almeira-store-app/src/app/api/users/route.ts
Normal file
@ -0,0 +1,57 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { dataService } from '@/lib/data-service';
|
||||
import { User } from '@/types';
|
||||
|
||||
export async function GET() {
|
||||
const users = dataService.getUsers();
|
||||
return NextResponse.json({ success: true, users });
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const body = await request.json();
|
||||
const newUser: User = {
|
||||
id: `usr-${Date.now()}`,
|
||||
name: body.name,
|
||||
email: body.email,
|
||||
phone: body.phone,
|
||||
city: body.city || 'Cali',
|
||||
address: body.address || '',
|
||||
role: body.role || 'customer',
|
||||
isActive: body.isActive !== false,
|
||||
createdAt: new Date().toISOString().split('T')[0],
|
||||
totalOrders: 0,
|
||||
};
|
||||
const saved = dataService.addUser(newUser);
|
||||
return NextResponse.json({ success: true, user: saved });
|
||||
} catch (error: any) {
|
||||
return NextResponse.json({ success: false, error: error.message }, { status: 400 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function PUT(request: Request) {
|
||||
try {
|
||||
const { id, updates } = await request.json();
|
||||
const updated = dataService.updateUser(id, updates);
|
||||
if (!updated) {
|
||||
return NextResponse.json({ success: false, error: 'Usuario no encontrado' }, { status: 404 });
|
||||
}
|
||||
return NextResponse.json({ success: true, user: updated });
|
||||
} catch (error: any) {
|
||||
return NextResponse.json({ success: false, error: error.message }, { status: 400 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function DELETE(request: Request) {
|
||||
try {
|
||||
const { searchParams } = new URL(request.url);
|
||||
const id = searchParams.get('id');
|
||||
if (!id) {
|
||||
return NextResponse.json({ success: false, error: 'ID requerido' }, { status: 400 });
|
||||
}
|
||||
const deleted = dataService.deleteUser(id);
|
||||
return NextResponse.json({ success: deleted });
|
||||
} catch (error: any) {
|
||||
return NextResponse.json({ success: false, error: error.message }, { status: 400 });
|
||||
}
|
||||
}
|
||||
163
almeira-store-app/src/app/api/webhook/whatsapp/route.ts
Normal file
@ -0,0 +1,163 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { dataService } from '@/lib/data-service';
|
||||
import { parseSupplierMessage } from '@/lib/gemini-parser';
|
||||
import { SupplierPost, Product } from '@/types';
|
||||
import { calculateRetailPrice } from '@/lib/pricing-engine';
|
||||
|
||||
export async function GET() {
|
||||
const posts = dataService.getSupplierPosts();
|
||||
const settings = dataService.getSettings();
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
autoPublishEnabled: settings.autoPublishSupplierPosts ?? true,
|
||||
postsCount: posts.length,
|
||||
posts,
|
||||
});
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const payload = await request.json();
|
||||
console.log('📥 [WhatsApp Webhook] Mensaje recibido:', JSON.stringify(payload).slice(0, 300));
|
||||
|
||||
const data = payload.data || payload;
|
||||
const settings = dataService.getSettings();
|
||||
|
||||
// 1. Extraer texto del mensaje de WhatsApp
|
||||
const messageContent =
|
||||
data.message?.conversation ||
|
||||
data.message?.extendedTextMessage?.text ||
|
||||
data.message?.imageMessage?.caption ||
|
||||
data.text ||
|
||||
'';
|
||||
|
||||
// 2. Extraer nombre del canal o grupo
|
||||
const senderPhone = data.key?.remoteJid?.replace(/\D/g, '') || '';
|
||||
const isGroup = data.key?.remoteJid?.includes('g.us');
|
||||
const channelName =
|
||||
data.groupInfo?.subject ||
|
||||
data.pushName ||
|
||||
(isGroup ? 'Grupo Mayorista WhatsApp' : 'Chat Proveedor Directo');
|
||||
|
||||
if (!messageContent || messageContent.trim().length < 6) {
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
ignored: 'Mensaje sin texto suficiente para extraer calzado.',
|
||||
});
|
||||
}
|
||||
|
||||
// 3. Buscar proveedor asociado por teléfono o grupo
|
||||
const suppliers = dataService.getSuppliers();
|
||||
const matchedSupplier = suppliers.find(
|
||||
(s) =>
|
||||
s.phone.includes(senderPhone) ||
|
||||
(channelName && s.name.toLowerCase().includes(channelName.toLowerCase())) ||
|
||||
(channelName && channelName.toLowerCase().includes(s.name.toLowerCase()))
|
||||
);
|
||||
|
||||
const supplierName = matchedSupplier?.name || channelName;
|
||||
const supplierPhone = matchedSupplier?.phone || senderPhone || '573167455877';
|
||||
|
||||
// 4. Procesar y extraer información con Gemini AI
|
||||
const extracted = await parseSupplierMessage(messageContent);
|
||||
|
||||
// Recalcular precio con el margen y redondeo al $5.000 oficial
|
||||
const finalRetailPrice = calculateRetailPrice(
|
||||
extracted.supplierPrice,
|
||||
settings.defaultProfitMargin || 35
|
||||
);
|
||||
|
||||
// Extraer imágenes (si Evolution API envió imagen adjunta o usar fotos de alta resolución)
|
||||
const images: string[] = [];
|
||||
if (data.message?.imageMessage?.url) {
|
||||
images.push(data.message.imageMessage.url);
|
||||
} else {
|
||||
images.push(
|
||||
'https://images.unsplash.com/photo-1595950653106-6c9ebd614d3a?auto=format&fit=crop&w=800&q=80'
|
||||
);
|
||||
}
|
||||
|
||||
// 5. Verificar Anti-Duplicados en el Catálogo
|
||||
const existingProducts = dataService.getProducts();
|
||||
const duplicateProduct = existingProducts.find(
|
||||
(p) => p.name.toLowerCase() === extracted.name.toLowerCase()
|
||||
);
|
||||
|
||||
let registeredProduct: Product | null = null;
|
||||
const autoPublish = settings.autoPublishSupplierPosts ?? true;
|
||||
|
||||
if (autoPublish) {
|
||||
if (duplicateProduct) {
|
||||
// Actualizar stock y tallas existentes sin duplicar
|
||||
const mergedSizes = Array.from(new Set([...duplicateProduct.sizes, ...extracted.sizes]));
|
||||
registeredProduct = dataService.updateProduct(duplicateProduct.id, {
|
||||
supplierPrice: extracted.supplierPrice,
|
||||
retailPrice: finalRetailPrice,
|
||||
sizes: mergedSizes,
|
||||
inStock: true,
|
||||
isActive: true,
|
||||
supplierName,
|
||||
supplierPhone,
|
||||
});
|
||||
console.log(`🔄 [Auto-Sync] Zapatilla "${extracted.name}" actualizada en catálogo (Anti-Duplicados).`);
|
||||
} else {
|
||||
// Registrar y publicar automáticamente
|
||||
registeredProduct = dataService.addProduct({
|
||||
name: extracted.name,
|
||||
brand: extracted.brand,
|
||||
supplierPrice: extracted.supplierPrice,
|
||||
retailPrice: finalRetailPrice,
|
||||
originalPrice: Math.round(finalRetailPrice * 1.35),
|
||||
sizes: extracted.sizes,
|
||||
images,
|
||||
description: `Zapatillas ${extracted.brand} ${extracted.name} garantizadas. Calidad seleccionada con horma nacional y suela antideslizante.`,
|
||||
category: extracted.category || 'Sneakers / Urbano',
|
||||
inStock: true,
|
||||
isActive: true,
|
||||
isFlashDeal: true,
|
||||
rating: 4.9,
|
||||
reviewsCount: 15,
|
||||
salesCount: 30,
|
||||
originBadge: 'Bodega Mayorista',
|
||||
supplierId: matchedSupplier?.id || 'sup-auto',
|
||||
supplierName,
|
||||
supplierPhone,
|
||||
});
|
||||
console.log(`✨ [Auto-Publish] Nueva zapatilla "${extracted.name}" publicada automáticamente en tienda.`);
|
||||
}
|
||||
}
|
||||
|
||||
// 6. Registrar en el historial de publicaciones recibidas
|
||||
const newPost: SupplierPost = {
|
||||
id: `post-${Date.now()}`,
|
||||
channelName,
|
||||
supplierName,
|
||||
supplierPhone,
|
||||
rawMessage: messageContent,
|
||||
rawImages: images,
|
||||
extractedProduct: {
|
||||
...extracted,
|
||||
suggestedRetailPrice: finalRetailPrice,
|
||||
supplierName,
|
||||
supplierPhone,
|
||||
},
|
||||
status: autoPublish ? 'published_store' : 'pending_review',
|
||||
receivedAt: 'Justo ahora',
|
||||
};
|
||||
|
||||
dataService.addSupplierPost(newPost);
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
autoPublished: autoPublish,
|
||||
product: registeredProduct,
|
||||
post: newPost,
|
||||
message: autoPublish
|
||||
? `🔥 ¡Zapatilla "${extracted.name}" procesada y publicada AUTOMÁTICAMENTE en la tienda!`
|
||||
: `Mensaje ingresado al feed para aprobación manual.`,
|
||||
});
|
||||
} catch (error: any) {
|
||||
console.error('Error procesando webhook de WhatsApp:', error);
|
||||
return NextResponse.json({ success: false, error: error.message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
104
almeira-store-app/src/app/api/whatsapp-groups/route.ts
Normal file
@ -0,0 +1,104 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { dataService } from '@/lib/data-service';
|
||||
import { MonitoredWhatsAppGroup } from '@/types';
|
||||
|
||||
const EVOLUTION_API_URL = process.env.EVOLUTION_API_URL || 'https://evolution.almeira.com.co';
|
||||
const EVOLUTION_API_KEY = process.env.EVOLUTION_API_KEY || 'EE49029EEB85-4568-8F81-C9FDFC6E2943';
|
||||
const EVOLUTION_INSTANCE_NAME = process.env.EVOLUTION_INSTANCE_NAME || 'AutomataAlmeira';
|
||||
|
||||
export async function GET(request: Request) {
|
||||
try {
|
||||
const { searchParams } = new URL(request.url);
|
||||
const fetchLive = searchParams.get('fetchLive') === 'true';
|
||||
|
||||
const localGroups = dataService.getMonitoredGroups();
|
||||
|
||||
if (!fetchLive) {
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
groups: localGroups,
|
||||
});
|
||||
}
|
||||
|
||||
// Consultar grupos en vivo desde Evolution API
|
||||
try {
|
||||
const response = await fetch(
|
||||
`${EVOLUTION_API_URL}/group/fetchAllGroups/${EVOLUTION_INSTANCE_NAME}?getParticipants=false`,
|
||||
{
|
||||
headers: {
|
||||
apikey: EVOLUTION_API_KEY,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
const liveGroups = await response.json();
|
||||
|
||||
if (Array.isArray(liveGroups)) {
|
||||
const merged: MonitoredWhatsAppGroup[] = liveGroups.map((lg: any) => {
|
||||
const jid = lg.id || lg.jid;
|
||||
const name = lg.subject || lg.name || 'Grupo Sin Nombre';
|
||||
const existing = localGroups.find((g) => g.jid === jid || g.name.toLowerCase() === name.toLowerCase());
|
||||
|
||||
return {
|
||||
id: existing?.id || `grp-${jid.replace(/\D/g, '').slice(-6)}`,
|
||||
jid,
|
||||
name,
|
||||
isMonitored: existing ? existing.isMonitored : false,
|
||||
supplierId: existing?.supplierId,
|
||||
supplierName: existing?.supplierName || name,
|
||||
profitMarginOverride: existing?.profitMarginOverride || 35,
|
||||
autoPublish: existing?.autoPublish ?? true,
|
||||
participantsCount: lg.size || (lg.participants ? lg.participants.length : undefined),
|
||||
notes: existing?.notes || '',
|
||||
};
|
||||
});
|
||||
|
||||
// Asegurar que los locales que no vinieron en live sigan presentes
|
||||
localGroups.forEach((lg) => {
|
||||
if (!merged.some((m) => m.jid === lg.jid || m.id === lg.id)) {
|
||||
merged.unshift(lg);
|
||||
}
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
isLive: true,
|
||||
totalLive: liveGroups.length,
|
||||
groups: merged,
|
||||
});
|
||||
}
|
||||
} catch (err: any) {
|
||||
console.warn('No se pudo conectar con Evolution API en vivo:', err.message);
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
isLive: false,
|
||||
groups: localGroups,
|
||||
});
|
||||
} catch (error: any) {
|
||||
return NextResponse.json({ success: false, error: error.message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const body = await request.json();
|
||||
|
||||
if (body.action === 'toggle') {
|
||||
const { idOrJid, isMonitored } = body;
|
||||
const updated = dataService.toggleGroupMonitoring(idOrJid, isMonitored);
|
||||
return NextResponse.json({ success: true, group: updated });
|
||||
}
|
||||
|
||||
if (Array.isArray(body.groups)) {
|
||||
const saved = dataService.saveMonitoredGroups(body.groups);
|
||||
return NextResponse.json({ success: true, groups: saved });
|
||||
}
|
||||
|
||||
return NextResponse.json({ success: false, error: 'Datos no válidos' }, { status: 400 });
|
||||
} catch (error: any) {
|
||||
return NextResponse.json({ success: false, error: error.message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
476
almeira-store-app/src/app/checkout/page.tsx
Normal file
@ -0,0 +1,476 @@
|
||||
'use client';
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { ArrowLeft, CheckCircle2, ShieldCheck, Truck, Zap, QrCode, Phone, MapPin, User, MessageCircle, ExternalLink, CreditCard } from 'lucide-react';
|
||||
import { City, PaymentMethod, Order, StoreSettings } from '@/types';
|
||||
import { INITIAL_CITIES, INITIAL_PAYMENT_METHODS, INITIAL_PRODUCTS } from '@/lib/data-service';
|
||||
import { formatCOP } from '@/lib/pricing-engine';
|
||||
import { useBrand } from '@/context/BrandContext';
|
||||
import confetti from 'canvas-confetti';
|
||||
|
||||
export default function CheckoutPage() {
|
||||
const { currentBrand } = useBrand();
|
||||
|
||||
const [customerName, setCustomerName] = useState('');
|
||||
const [customerPhone, setCustomerPhone] = useState('');
|
||||
const [address, setAddress] = useState('');
|
||||
const [neighborhood, setNeighborhood] = useState('');
|
||||
const [notes, setNotes] = useState('');
|
||||
|
||||
const [cities, setCities] = useState<City[]>(INITIAL_CITIES);
|
||||
const [paymentMethods, setPaymentMethods] = useState<PaymentMethod[]>(INITIAL_PAYMENT_METHODS);
|
||||
const [selectedCityId, setSelectedCityId] = useState<string>('city-cali');
|
||||
const [selectedPaymentId, setSelectedPaymentId] = useState<string>('pay-deposit-10k');
|
||||
const [storeSettings, setStoreSettings] = useState<StoreSettings | null>(null);
|
||||
|
||||
const [checkoutItems] = useState<Array<{
|
||||
productId: string;
|
||||
productName: string;
|
||||
brand: string;
|
||||
size: string;
|
||||
quantity: number;
|
||||
price: number;
|
||||
image: string;
|
||||
}>>([
|
||||
{
|
||||
productId: INITIAL_PRODUCTS[0].id,
|
||||
productName: INITIAL_PRODUCTS[0].name,
|
||||
brand: INITIAL_PRODUCTS[0].brand,
|
||||
size: '39',
|
||||
quantity: 1,
|
||||
price: INITIAL_PRODUCTS[0].retailPrice,
|
||||
image: INITIAL_PRODUCTS[0].images[0],
|
||||
},
|
||||
]);
|
||||
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [orderCompleted, setOrderCompleted] = useState<Order | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
fetch('/api/cities')
|
||||
.then((r) => r.json())
|
||||
.then((d) => d.success && setCities(d.cities))
|
||||
.catch(() => {});
|
||||
|
||||
fetch('/api/payment-methods')
|
||||
.then((r) => r.json())
|
||||
.then((d) => d.success && setPaymentMethods(d.paymentMethods))
|
||||
.catch(() => {});
|
||||
|
||||
fetch('/api/settings')
|
||||
.then((r) => r.json())
|
||||
.then((d) => d.success && setStoreSettings(d.settings))
|
||||
.catch(() => {});
|
||||
}, []);
|
||||
|
||||
const activeCity = cities.find((c) => c.id === selectedCityId) || cities[0];
|
||||
const activePayment = paymentMethods.find((p) => p.id === selectedPaymentId) || paymentMethods[0];
|
||||
|
||||
const subtotal = checkoutItems.reduce((acc, it) => acc + it.price * it.quantity, 0);
|
||||
const shippingCost = activeCity.shippingCost;
|
||||
const totalAmount = subtotal + shippingCost;
|
||||
|
||||
const depositAmount = activePayment.requiresDeposit ? activePayment.depositAmount : 0;
|
||||
const balanceDue = totalAmount - depositAmount;
|
||||
|
||||
const officialWhatsapp = storeSettings?.whatsappNumber || '573167455877';
|
||||
const boldLink = storeSettings?.boldPaymentLink || 'https://bold.co/p/carlos-lasso';
|
||||
|
||||
const handleSubmitOrder = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!customerName || !customerPhone || !address) {
|
||||
alert('Por favor completa tu nombre, celular y dirección para el despacho.');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSubmitting(true);
|
||||
|
||||
try {
|
||||
const orderPayload = {
|
||||
customerName,
|
||||
customerPhone,
|
||||
address: `${address} - Barrio: ${neighborhood || 'Centro'}`,
|
||||
cityId: activeCity.id,
|
||||
cityName: activeCity.name,
|
||||
items: checkoutItems,
|
||||
subtotal,
|
||||
shippingCost,
|
||||
totalAmount,
|
||||
depositAmount,
|
||||
balanceDue,
|
||||
paymentMethodId: activePayment.id,
|
||||
paymentMethodName: activePayment.name,
|
||||
notes,
|
||||
};
|
||||
|
||||
const res = await fetch('/api/orders', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(orderPayload),
|
||||
});
|
||||
|
||||
const data = await res.json();
|
||||
if (data.success) {
|
||||
setOrderCompleted(data.order);
|
||||
confetti({
|
||||
particleCount: 100,
|
||||
spread: 70,
|
||||
origin: { y: 0.6 },
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error creando orden:', err);
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleOpenWhatsAppOrder = (order: Order) => {
|
||||
const text = `🎉 *CONFIRMACIÓN DE PEDIDO EN ${currentBrand.name} #${order.id}* 🎉
|
||||
|
||||
👤 *Cliente:* ${order.customerName}
|
||||
📱 *Teléfono:* ${order.customerPhone}
|
||||
📍 *Dirección:* ${order.address}
|
||||
🏙️ *Ciudad:* ${order.cityName}
|
||||
|
||||
📦 *Artículos:*
|
||||
${order.items.map((i) => `• ${i.productName} (Talla ${i.size}) x${i.quantity} = ${formatCOP(i.price * i.quantity)}`).join('\n')}
|
||||
|
||||
💰 *Total:* ${formatCOP(order.totalAmount)}
|
||||
${order.depositAmount > 0 ? `⚡ *Separación:* ${formatCOP(order.depositAmount)}\n🤝 *Saldo Contraentrega:* ${formatCOP(order.balanceDue)}` : '🤝 *Pago 100% Contraentrega.*'}
|
||||
|
||||
💳 *Método de Pago:* ${order.paymentMethodName}
|
||||
|
||||
¡Hola ${currentBrand.shortName}! Acabo de registrar mi pedido en la web. ¿Me confirman despacho por favor?`;
|
||||
|
||||
window.open(`https://wa.me/${officialWhatsapp}?text=${encodeURIComponent(text)}`, '_blank');
|
||||
};
|
||||
|
||||
if (orderCompleted) {
|
||||
return (
|
||||
<div className={`min-h-screen ${currentBrand.colors.bodyBg} flex items-center justify-center p-4`}>
|
||||
<div className={`bg-slate-900 border ${currentBrand.colors.border} rounded-3xl max-w-lg w-full p-6 sm:p-8 shadow-2xl text-center space-y-5 animate-in zoom-in-95 duration-300`}>
|
||||
<div className={`w-16 h-16 rounded-full bg-gradient-to-r ${currentBrand.colors.primaryGradient} text-white flex items-center justify-center mx-auto shadow-md`}>
|
||||
<CheckCircle2 className="w-10 h-10" />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<span className={`text-xs font-bold bg-white/10 text-white px-3 py-1 rounded-full uppercase tracking-wider border border-white/10`}>
|
||||
¡Pedido Registrado con Éxito!
|
||||
</span>
|
||||
<h2 className="text-2xl font-black text-white mt-2">Orden #{orderCompleted.id}</h2>
|
||||
<p className="text-xs text-slate-400 mt-1">
|
||||
Hemos recibido tu solicitud para entrega en <strong>{orderCompleted.cityName}</strong> con <strong>{currentBrand.name}</strong>.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Bold.co or Nequi Separation Box */}
|
||||
{orderCompleted.depositAmount > 0 ? (
|
||||
<div className="bg-slate-950 border border-white/10 rounded-2xl p-4 text-left space-y-2.5">
|
||||
<div className="flex items-center space-x-2 text-white font-bold text-xs uppercase">
|
||||
<QrCode className="w-4 h-4 text-white" />
|
||||
<span>Paso 2: Realiza la separación de $10.000 COP</span>
|
||||
</div>
|
||||
<p className="text-xs text-slate-300">
|
||||
Paga de inmediato con <strong>Link de Pago Bold.co</strong> (PSE / Tarjeta / Nequi) o transfiere a nuestra cuenta oficial:
|
||||
</p>
|
||||
|
||||
{boldLink && (
|
||||
<a
|
||||
href={boldLink}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="w-full bg-indigo-600 hover:bg-indigo-500 text-white font-black text-xs py-2.5 px-3 rounded-xl flex items-center justify-center gap-1.5 shadow-md transition-all"
|
||||
>
|
||||
<CreditCard className="w-4 h-4" />
|
||||
<span>Pagar $10.000 COP con Bold.co (PSE / Tarjetas)</span>
|
||||
<ExternalLink className="w-3.5 h-3.5" />
|
||||
</a>
|
||||
)}
|
||||
|
||||
<div className="bg-slate-900 p-2.5 rounded-xl border border-slate-700 text-xs font-mono font-bold text-white">
|
||||
📱 Nequi / Daviplata: <strong>{storeSettings?.nequiAccount || '316 745 5877'}</strong> ({storeSettings?.accountHolder || 'Carlos Andrés Lasso'})
|
||||
</div>
|
||||
<p className="text-[11px] text-emerald-400 font-semibold">
|
||||
🤝 Cancelas el saldo de <strong>{formatCOP(orderCompleted.balanceDue)}</strong> al recibir el paquete en tu casa.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="bg-slate-950 border border-white/10 rounded-2xl p-4 text-left space-y-2">
|
||||
<p className="text-xs text-slate-300 font-semibold">
|
||||
Tu pedido quedó programado para entrega contraentrega en 24 horas. ¡Nos pondremos en contacto contigo por WhatsApp!
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex flex-col gap-2 pt-2">
|
||||
<button
|
||||
onClick={() => handleOpenWhatsAppOrder(orderCompleted)}
|
||||
className="w-full bg-emerald-600 hover:bg-emerald-700 text-white font-bold text-sm py-3 px-4 rounded-xl flex items-center justify-center space-x-2 shadow-lg shadow-emerald-600/20 active:scale-95 transition-all cursor-pointer"
|
||||
>
|
||||
<MessageCircle className="w-4 h-4 fill-white" />
|
||||
<span>Confirmar y Enviar Comprobante por WhatsApp</span>
|
||||
</button>
|
||||
|
||||
<Link
|
||||
href="/"
|
||||
className="w-full bg-slate-800 hover:bg-slate-700 text-slate-300 font-bold text-xs py-2.5 px-4 rounded-xl text-center transition-all border border-slate-700"
|
||||
>
|
||||
Volver a la Tienda
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`min-h-screen ${currentBrand.colors.bodyBg} py-8 px-4 font-sans`}>
|
||||
<div className="max-w-4xl mx-auto space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<Link
|
||||
href="/"
|
||||
className="flex items-center space-x-2 text-xs font-bold text-slate-400 hover:text-white transition-colors"
|
||||
>
|
||||
<ArrowLeft className="w-4 h-4" />
|
||||
<span>Regresar a {currentBrand.name}</span>
|
||||
</Link>
|
||||
<div className="flex items-center space-x-1.5 text-xs font-bold text-white bg-white/10 border border-white/10 px-3 py-1 rounded-full shadow-sm">
|
||||
<ShieldCheck className="w-4 h-4" />
|
||||
<span>Compra Segura & Garantizada</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmitOrder} className="grid grid-cols-1 md:grid-cols-12 gap-6">
|
||||
{/* Left Column: Shipping & Payment Details (7 cols) */}
|
||||
<div className="md:col-span-7 space-y-5">
|
||||
{/* 1. Datos del Cliente */}
|
||||
<div className="bg-slate-900 border border-slate-800 rounded-3xl p-5 sm:p-6 shadow-sm space-y-4">
|
||||
<h3 className="font-black text-white text-base flex items-center gap-2">
|
||||
<User className="w-5 h-5 text-white" />
|
||||
<span>1. Datos de Entrega</span>
|
||||
</h3>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="block text-xs font-bold text-slate-300 mb-1">Nombre Completo *</label>
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
placeholder="Ej. Carlos Andrés Lasso"
|
||||
value={customerName}
|
||||
onChange={(e) => setCustomerName(e.target.value)}
|
||||
className="w-full bg-slate-950 border border-slate-800 rounded-xl px-3 py-2 text-sm text-white focus:border-white outline-hidden"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-bold text-slate-300 mb-1">Celular / WhatsApp *</label>
|
||||
<input
|
||||
type="tel"
|
||||
required
|
||||
placeholder="Ej. 3167455877"
|
||||
value={customerPhone}
|
||||
onChange={(e) => setCustomerPhone(e.target.value)}
|
||||
className="w-full bg-slate-950 border border-slate-800 rounded-xl px-3 py-2 text-sm text-white focus:border-white outline-hidden"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* City Selection */}
|
||||
<div>
|
||||
<label className="block text-xs font-bold text-slate-300 mb-1">Ciudad de Despacho *</label>
|
||||
<select
|
||||
value={selectedCityId}
|
||||
onChange={(e) => setSelectedCityId(e.target.value)}
|
||||
className="w-full bg-slate-950 border border-slate-800 rounded-xl px-3 py-2.5 text-sm text-white focus:border-white font-semibold outline-hidden"
|
||||
>
|
||||
{cities.map((city) => (
|
||||
<option key={city.id} value={city.id} className="bg-slate-900">
|
||||
{city.name} — {city.shippingCost === 0 ? 'Envío Gratis' : formatCOP(city.shippingCost)} ({city.deliveryTime})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="block text-xs font-bold text-slate-300 mb-1">Dirección de Entrega *</label>
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
placeholder="Calle, Carrera, No. Casa/Apto"
|
||||
value={address}
|
||||
onChange={(e) => setAddress(e.target.value)}
|
||||
className="w-full bg-slate-950 border border-slate-800 rounded-xl px-3 py-2 text-sm text-white focus:border-white outline-hidden"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-bold text-slate-300 mb-1">Barrio / Sector</label>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Ej. San Fernando / Capri"
|
||||
value={neighborhood}
|
||||
onChange={(e) => setNeighborhood(e.target.value)}
|
||||
className="w-full bg-slate-950 border border-slate-800 rounded-xl px-3 py-2 text-sm text-white focus:border-white outline-hidden"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 2. Método de Pago */}
|
||||
<div className="bg-slate-900 border border-slate-800 rounded-3xl p-5 sm:p-6 shadow-sm space-y-4">
|
||||
<h3 className="font-black text-white text-base flex items-center gap-2">
|
||||
<Zap className="w-5 h-5 text-white" />
|
||||
<span>2. Selecciona tu Método de Pago</span>
|
||||
</h3>
|
||||
|
||||
<div className="space-y-2.5">
|
||||
{paymentMethods.map((method) => {
|
||||
const isSelected = selectedPaymentId === method.id;
|
||||
return (
|
||||
<div
|
||||
key={method.id}
|
||||
onClick={() => setSelectedPaymentId(method.id)}
|
||||
className={`p-4 rounded-2xl border transition-all cursor-pointer space-y-1.5 ${
|
||||
isSelected
|
||||
? `border-cyan-400 bg-cyan-950/20 shadow-md`
|
||||
: 'border-slate-800 hover:border-slate-700 bg-slate-950'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center space-x-2">
|
||||
<div
|
||||
className={`w-4 h-4 rounded-full border-2 flex items-center justify-center ${
|
||||
isSelected ? 'border-cyan-400' : 'border-slate-600'
|
||||
}`}
|
||||
>
|
||||
{isSelected && <div className="w-2 h-2 rounded-full bg-cyan-400" />}
|
||||
</div>
|
||||
<span className="font-black text-white text-sm">{method.name}</span>
|
||||
</div>
|
||||
|
||||
{method.requiresDeposit && (
|
||||
<span className="bg-white/10 text-white border border-white/20 text-[10px] font-black px-2 py-0.5 rounded-full uppercase">
|
||||
Separa con $10.000
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<p className="text-xs text-slate-400 pl-6">{method.description}</p>
|
||||
|
||||
{isSelected && (method.accountInfo || (method.type === 'bold' && boldLink)) && (
|
||||
<div className="ml-6 mt-2 bg-slate-900 p-3 rounded-xl border border-white/10 text-xs space-y-2 text-white">
|
||||
{method.accountInfo && <p className="font-semibold">{method.accountInfo}</p>}
|
||||
{method.type === 'bold' && boldLink && (
|
||||
<a
|
||||
href={boldLink}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-1.5 bg-indigo-600 hover:bg-indigo-500 text-white text-xs font-bold px-3 py-1.5 rounded-lg shadow-sm"
|
||||
>
|
||||
<ExternalLink className="w-3.5 h-3.5" />
|
||||
<span>Abrir Link de Pago Bold.co</span>
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right Column: Summary (5 cols) */}
|
||||
<div className="md:col-span-5 space-y-5">
|
||||
<div className="bg-slate-900 border border-slate-800 rounded-3xl p-5 sm:p-6 shadow-sm space-y-4 sticky top-20">
|
||||
<h3 className="font-black text-white text-base">Resumen del Pedido</h3>
|
||||
|
||||
{/* Items */}
|
||||
<div className="space-y-3 max-h-60 overflow-y-auto pr-1">
|
||||
{checkoutItems.map((item, i) => (
|
||||
<div key={i} className="flex gap-3 bg-slate-950 p-2.5 rounded-2xl border border-slate-800">
|
||||
<img src={item.image} alt="" className="w-14 h-14 rounded-xl object-cover bg-slate-900 border border-slate-700" />
|
||||
<div className="flex-1 text-xs">
|
||||
<h4 className="font-bold text-white line-clamp-1">{item.productName}</h4>
|
||||
<p className="text-slate-400">Talla: <strong className="text-white">{item.size}</strong> | Cant: {item.quantity}</p>
|
||||
<p className={`font-black mt-0.5 text-transparent bg-clip-text bg-gradient-to-r ${currentBrand.colors.primaryGradient}`}>
|
||||
{formatCOP(item.price * item.quantity)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Breakdown */}
|
||||
<div className="border-t border-slate-800 pt-3 space-y-2 text-xs text-slate-400">
|
||||
<div className="flex justify-between">
|
||||
<span>Subtotal:</span>
|
||||
<span className="font-bold text-white">{formatCOP(subtotal)}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span>Envío ({activeCity.name}):</span>
|
||||
<span className="font-bold text-white">
|
||||
{shippingCost === 0 ? <span className="text-emerald-400 font-bold uppercase">Gratis</span> : formatCOP(shippingCost)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-base font-black text-white pt-2 border-t border-slate-800">
|
||||
<span>Total a Pagar:</span>
|
||||
<span className={`text-transparent bg-clip-text bg-gradient-to-r ${currentBrand.colors.primaryGradient} text-lg`}>
|
||||
{formatCOP(totalAmount)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{depositAmount > 0 && (
|
||||
<div className="bg-white/5 border border-white/10 rounded-xl p-2.5 text-xs text-white space-y-1">
|
||||
<div className="flex justify-between font-bold text-white">
|
||||
<span>Separación por Bold / Nequi / Daviplata:</span>
|
||||
<span>{formatCOP(depositAmount)}</span>
|
||||
</div>
|
||||
<div className="flex justify-between font-black text-white pt-1 border-t border-white/10">
|
||||
<span>Saldo a pagar al mensajero:</span>
|
||||
<span>{formatCOP(balanceDue)}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="bg-white/5 border border-white/10 rounded-xl p-2.5 text-xs text-slate-300 space-y-1">
|
||||
<div className="flex items-center space-x-1.5 font-bold text-white">
|
||||
<ShieldCheck className={`w-3.5 h-3.5 ${activeCity.firstExchangeFree ? 'text-emerald-400' : 'text-cyan-400'}`} />
|
||||
<span>Garantía en {activeCity.name}:</span>
|
||||
</div>
|
||||
<p className="text-[11px] text-slate-300">
|
||||
{activeCity.firstExchangeFree
|
||||
? '1er Cambio de Talla 100% GRATIS (cambios posteriores con flete estándar).'
|
||||
: 'Cambios de talla 100% garantizados (los costos de envío son asumidos por el cliente).'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isSubmitting}
|
||||
className={`w-full bg-gradient-to-r ${currentBrand.colors.primaryGradient} hover:opacity-95 text-white font-black text-sm py-3.5 px-4 rounded-2xl shadow-lg active:scale-95 transition-all cursor-pointer flex items-center justify-center space-x-2`}
|
||||
>
|
||||
{isSubmitting ? (
|
||||
<span>Procesando Pedido...</span>
|
||||
) : (
|
||||
<>
|
||||
<span>Confirmar Pedido Contraentrega</span>
|
||||
<Truck className="w-4 h-4" />
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
|
||||
<p className="text-[11px] text-slate-500 text-center leading-tight">
|
||||
Al confirmar, se guardará tu pedido y recibirás asistencia inmediata por WhatsApp oficial ({officialWhatsapp}) para el seguimiento de tu entrega en 24 horas.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
BIN
almeira-store-app/src/app/favicon.ico
Normal file
|
After Width: | Height: | Size: 25 KiB |
26
almeira-store-app/src/app/globals.css
Normal file
@ -0,0 +1,26 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
:root {
|
||||
--background: #ffffff;
|
||||
--foreground: #171717;
|
||||
}
|
||||
|
||||
@theme inline {
|
||||
--color-background: var(--background);
|
||||
--color-foreground: var(--foreground);
|
||||
--font-sans: var(--font-geist-sans);
|
||||
--font-mono: var(--font-geist-mono);
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--background: #0a0a0a;
|
||||
--foreground: #ededed;
|
||||
}
|
||||
}
|
||||
|
||||
body {
|
||||
background: var(--background);
|
||||
color: var(--foreground);
|
||||
font-family: Arial, Helvetica, sans-serif;
|
||||
}
|
||||
61
almeira-store-app/src/app/layout.tsx
Normal file
@ -0,0 +1,61 @@
|
||||
import type { Metadata, Viewport } from 'next';
|
||||
import { Geist, Geist_Mono } from 'next/font/google';
|
||||
import './globals.css';
|
||||
import { BrandProvider } from '@/context/BrandContext';
|
||||
import { AuthProvider } from '@/context/AuthContext';
|
||||
|
||||
const geistSans = Geist({
|
||||
variable: '--font-geist-sans',
|
||||
subsets: ['latin'],
|
||||
});
|
||||
|
||||
const geistMono = Geist_Mono({
|
||||
variable: '--font-geist-mono',
|
||||
subsets: ['latin'],
|
||||
});
|
||||
|
||||
export const viewport: Viewport = {
|
||||
themeColor: '#06b6d4',
|
||||
width: 'device-width',
|
||||
initialScale: 1,
|
||||
maximumScale: 1,
|
||||
userScalable: false,
|
||||
viewportFit: 'cover',
|
||||
};
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Almeira Store • Sneakers & Moda Urbana',
|
||||
description: 'Tienda oficial de calzado y zapatillas con entregas contraentrega en 24 horas.',
|
||||
manifest: '/manifest.json',
|
||||
appleWebApp: {
|
||||
capable: true,
|
||||
statusBarStyle: 'black-translucent',
|
||||
title: 'Almeira Store',
|
||||
},
|
||||
icons: {
|
||||
icon: '/icon.svg',
|
||||
apple: '/icon.svg',
|
||||
},
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<html lang="es" className={`${geistSans.variable} ${geistMono.variable} h-full antialiased`}>
|
||||
<head>
|
||||
<meta name="mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
</head>
|
||||
<body className="min-h-full flex flex-col bg-slate-950 text-slate-100 pb-16 sm:pb-0">
|
||||
<AuthProvider>
|
||||
<BrandProvider>
|
||||
{children}
|
||||
</BrandProvider>
|
||||
</AuthProvider>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
284
almeira-store-app/src/app/page.tsx
Normal file
@ -0,0 +1,284 @@
|
||||
'use client';
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Navbar } from '@/components/store/Navbar';
|
||||
import { FlashDealsBanner } from '@/components/store/FlashDealsBanner';
|
||||
import { CategoryPills } from '@/components/store/CategoryPills';
|
||||
import { ProductCard } from '@/components/store/ProductCard';
|
||||
import { ProductModal } from '@/components/store/ProductModal';
|
||||
import { CartDrawer } from '@/components/store/CartDrawer';
|
||||
import { CitySelectorModal } from '@/components/store/CitySelectorModal';
|
||||
import { MobileBottomDock } from '@/components/mobile/MobileBottomDock';
|
||||
import { MobileInstallBanner } from '@/components/mobile/MobileInstallBanner';
|
||||
import { AuthModal } from '@/components/auth/AuthModal';
|
||||
import { Product, City, CartItem } from '@/types';
|
||||
import { INITIAL_PRODUCTS, INITIAL_CITIES } from '@/lib/data-service';
|
||||
import { Sparkles, ShieldCheck, Truck, RefreshCw, Zap } from 'lucide-react';
|
||||
import { useBrand } from '@/context/BrandContext';
|
||||
|
||||
export default function StoreHomePage() {
|
||||
const { currentBrand } = useBrand();
|
||||
const [products, setProducts] = useState<Product[]>(INITIAL_PRODUCTS);
|
||||
const [cities, setCities] = useState<City[]>(INITIAL_CITIES);
|
||||
const [selectedCity, setSelectedCity] = useState<City>(INITIAL_CITIES[0]); // Cali
|
||||
const [activeCategory, setActiveCategory] = useState<string>('all');
|
||||
const [searchQuery, setSearchQuery] = useState<string>('');
|
||||
|
||||
// Modals & Drawers state
|
||||
const [selectedProduct, setSelectedProduct] = useState<Product | null>(null);
|
||||
const [isCartOpen, setIsCartOpen] = useState(false);
|
||||
const [isCityModalOpen, setIsCityModalOpen] = useState(false);
|
||||
const [isInstallModalOpen, setIsInstallModalOpen] = useState(false);
|
||||
const [cartItems, setCartItems] = useState<CartItem[]>([]);
|
||||
|
||||
const cartCount = cartItems.reduce((acc, item) => acc + item.quantity, 0);
|
||||
|
||||
useEffect(() => {
|
||||
fetch('/api/products')
|
||||
.then((res) => res.json())
|
||||
.then((data) => {
|
||||
if (data.success && data.products?.length > 0) {
|
||||
setProducts(data.products);
|
||||
}
|
||||
})
|
||||
.catch((e) => console.log('Usando catálogo inicial:', e));
|
||||
|
||||
fetch('/api/cities')
|
||||
.then((res) => res.json())
|
||||
.then((data) => {
|
||||
if (data.success && data.cities?.length > 0) {
|
||||
setCities(data.cities);
|
||||
const defaultCali = data.cities.find((c: City) => c.name.toLowerCase().includes('cali'));
|
||||
if (defaultCali) setSelectedCity(defaultCali);
|
||||
}
|
||||
})
|
||||
.catch((e) => console.log('Usando ciudades iniciales:', e));
|
||||
}, []);
|
||||
|
||||
const filteredProducts = products.filter((p) => {
|
||||
if (searchQuery.trim()) {
|
||||
const q = searchQuery.toLowerCase();
|
||||
const matchName = p.name.toLowerCase().includes(q);
|
||||
const matchBrand = p.brand.toLowerCase().includes(q);
|
||||
const matchSize = p.sizes.some((s) => s.includes(q));
|
||||
if (!matchName && !matchBrand && !matchSize) return false;
|
||||
}
|
||||
|
||||
if (activeCategory === 'flash') return p.isFlashDeal;
|
||||
if (activeCategory === 'under120') return p.retailPrice <= 120000;
|
||||
if (activeCategory !== 'all') {
|
||||
return p.brand.toLowerCase() === activeCategory.toLowerCase();
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
const handleAddToCart = (product: Product, size: string) => {
|
||||
setCartItems((prev) => {
|
||||
const existingIdx = prev.findIndex(
|
||||
(item) => item.product.id === product.id && item.selectedSize === size
|
||||
);
|
||||
if (existingIdx > -1) {
|
||||
const next = [...prev];
|
||||
next[existingIdx].quantity += 1;
|
||||
return next;
|
||||
}
|
||||
return [...prev, { product, selectedSize: size, quantity: 1 }];
|
||||
});
|
||||
setIsCartOpen(true);
|
||||
};
|
||||
|
||||
const handleRemoveFromCart = (productId: string, size: string) => {
|
||||
setCartItems((prev) =>
|
||||
prev.filter((item) => !(item.product.id === productId && item.selectedSize === size))
|
||||
);
|
||||
};
|
||||
|
||||
const handleUpdateQuantity = (productId: string, size: string, delta: number) => {
|
||||
setCartItems((prev) =>
|
||||
prev
|
||||
.map((item) => {
|
||||
if (item.product.id === productId && item.selectedSize === size) {
|
||||
const newQty = item.quantity + delta;
|
||||
return newQty > 0 ? { ...item, quantity: newQty } : null;
|
||||
}
|
||||
return item;
|
||||
})
|
||||
.filter(Boolean) as CartItem[]
|
||||
);
|
||||
};
|
||||
|
||||
const totalCartCount = cartItems.reduce((acc, item) => acc + item.quantity, 0);
|
||||
|
||||
return (
|
||||
<div className={`min-h-screen flex flex-col font-sans transition-colors duration-500 ${currentBrand.colors.bodyBg}`}>
|
||||
{/* Navigation Header */}
|
||||
<Navbar
|
||||
cartCount={totalCartCount}
|
||||
onOpenCart={() => setIsCartOpen(true)}
|
||||
selectedCity={selectedCity}
|
||||
onOpenCitySelector={() => setIsCityModalOpen(true)}
|
||||
searchQuery={searchQuery}
|
||||
onSearchChange={setSearchQuery}
|
||||
/>
|
||||
|
||||
{/* Main Container */}
|
||||
<main className="max-w-7xl mx-auto px-4 py-6 flex-1 w-full space-y-6">
|
||||
{/* Flash Deals Hero Banner */}
|
||||
<FlashDealsBanner />
|
||||
|
||||
{/* Value Props Strip */}
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
|
||||
<div className="bg-slate-900/60 backdrop-blur-md p-3.5 rounded-2xl border border-slate-800 shadow-sm flex items-center space-x-3">
|
||||
<div className={`w-10 h-10 rounded-xl bg-gradient-to-r ${currentBrand.colors.primaryGradient} text-white flex items-center justify-center shrink-0 shadow-sm`}>
|
||||
<Truck className="w-5 h-5" />
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="text-xs font-bold text-white">Entrega en 24 Horas</h4>
|
||||
<p className="text-[11px] text-slate-400">Directo en Cali</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-slate-900/60 backdrop-blur-md p-3.5 rounded-2xl border border-slate-800 shadow-sm flex items-center space-x-3">
|
||||
<div className={`w-10 h-10 rounded-xl bg-gradient-to-r ${currentBrand.colors.primaryGradient} text-white flex items-center justify-center shrink-0 shadow-sm`}>
|
||||
<Zap className="w-5 h-5" />
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="text-xs font-bold text-white">Pago Contraentrega</h4>
|
||||
<p className="text-[11px] text-slate-400">Cancelas al recibir</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-slate-900/60 backdrop-blur-md p-3.5 rounded-2xl border border-slate-800 shadow-sm flex items-center space-x-3">
|
||||
<div className={`w-10 h-10 rounded-xl bg-gradient-to-r ${currentBrand.colors.primaryGradient} text-white flex items-center justify-center shrink-0 shadow-sm`}>
|
||||
<Sparkles className="w-5 h-5" />
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="text-xs font-bold text-white">Separa con $10.000</h4>
|
||||
<p className="text-[11px] text-slate-400">Aparta tu talla</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-slate-900/60 backdrop-blur-md p-3.5 rounded-2xl border border-slate-800 shadow-sm flex items-center space-x-3">
|
||||
<div className={`w-10 h-10 rounded-xl bg-gradient-to-r ${currentBrand.colors.primaryGradient} text-white flex items-center justify-center shrink-0 shadow-sm`}>
|
||||
<ShieldCheck className="w-5 h-5" />
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="text-xs font-bold text-white">Garantía Total</h4>
|
||||
<p className="text-[11px] text-slate-400">Cambio asegurado</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Category & Brand Filters */}
|
||||
<div className="pt-2">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h3 className="font-black text-white text-lg flex items-center gap-2">
|
||||
<Sparkles className="w-5 h-5 text-white animate-pulse" />
|
||||
<span>Colección de Calzado & Sneakers</span>
|
||||
</h3>
|
||||
<span className="text-xs font-bold text-slate-400">
|
||||
{filteredProducts.length} {filteredProducts.length === 1 ? 'modelo' : 'modelos'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<CategoryPills
|
||||
activeCategory={activeCategory}
|
||||
onSelectCategory={setActiveCategory}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Product Grid */}
|
||||
{filteredProducts.length === 0 ? (
|
||||
<div className="bg-slate-900/80 rounded-3xl p-12 text-center border border-slate-800 space-y-3">
|
||||
<div className="w-16 h-16 rounded-full bg-white/10 text-white flex items-center justify-center mx-auto">
|
||||
<RefreshCw className="w-8 h-8 animate-spin" />
|
||||
</div>
|
||||
<h4 className="font-bold text-white text-base">No encontramos zapatillas con ese criterio</h4>
|
||||
<p className="text-xs text-slate-400">Intenta buscar otra marca o restablecer los filtros.</p>
|
||||
<button
|
||||
onClick={() => {
|
||||
setActiveCategory('all');
|
||||
setSearchQuery('');
|
||||
}}
|
||||
className={`bg-gradient-to-r ${currentBrand.colors.primaryGradient} text-white font-bold text-xs px-4 py-2 rounded-full cursor-pointer shadow-md`}
|
||||
>
|
||||
Ver Todo el Catálogo
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-3.5 sm:gap-5">
|
||||
{filteredProducts.map((product) => (
|
||||
<ProductCard
|
||||
key={product.id}
|
||||
product={product}
|
||||
onOpenDetail={setSelectedProduct}
|
||||
onQuickAdd={(p, size) => handleAddToCart(p, size)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
|
||||
{/* Footer */}
|
||||
<footer className="bg-black/70 text-slate-400 mt-12 py-8 border-t border-white/5">
|
||||
<div className="max-w-7xl mx-auto px-4 flex flex-col md:flex-row items-center justify-between gap-4 text-xs">
|
||||
<div className="flex items-center space-x-2.5">
|
||||
<div className={`w-8 h-8 rounded-lg bg-gradient-to-br ${currentBrand.colors.primaryGradient} flex items-center justify-center text-white font-black text-sm shadow-sm`}>
|
||||
{currentBrand.logo.iconText}
|
||||
</div>
|
||||
<span className="font-bold text-white text-sm tracking-wider">{currentBrand.name}</span>
|
||||
</div>
|
||||
<p>© 2026 {currentBrand.name}. Despachos urbanos y nacionales con pago contraentrega.</p>
|
||||
<div className="flex items-center space-x-2 text-white font-semibold text-xs">
|
||||
<ShieldCheck className="w-4 h-4" />
|
||||
<span>{currentBrand.badge}</span>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
{/* Modals & Drawers */}
|
||||
<ProductModal
|
||||
product={selectedProduct}
|
||||
onClose={() => setSelectedProduct(null)}
|
||||
onAddToCart={handleAddToCart}
|
||||
selectedCity={selectedCity}
|
||||
/>
|
||||
|
||||
<CartDrawer
|
||||
isOpen={isCartOpen}
|
||||
onClose={() => setIsCartOpen(false)}
|
||||
cartItems={cartItems}
|
||||
onRemoveItem={handleRemoveFromCart}
|
||||
onUpdateQuantity={handleUpdateQuantity}
|
||||
selectedCity={selectedCity}
|
||||
onOpenCitySelector={() => {
|
||||
setIsCartOpen(false);
|
||||
setIsCityModalOpen(true);
|
||||
}}
|
||||
/>
|
||||
|
||||
<CitySelectorModal
|
||||
isOpen={isCityModalOpen}
|
||||
onClose={() => setIsCityModalOpen(false)}
|
||||
cities={cities}
|
||||
selectedCity={selectedCity}
|
||||
onSelectCity={setSelectedCity}
|
||||
/>
|
||||
|
||||
{/* Mobile App Dock & Install Banner */}
|
||||
<MobileBottomDock
|
||||
cartCount={cartCount}
|
||||
onOpenCart={() => setIsCartOpen(true)}
|
||||
onOpenInstallModal={() => setIsInstallModalOpen(true)}
|
||||
/>
|
||||
|
||||
<MobileInstallBanner
|
||||
isOpen={isInstallModalOpen}
|
||||
onClose={() => setIsInstallModalOpen(false)}
|
||||
/>
|
||||
|
||||
{/* User Auth Modal */}
|
||||
<AuthModal />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
255
almeira-store-app/src/components/auth/AuthModal.tsx
Normal file
@ -0,0 +1,255 @@
|
||||
'use client';
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import { X, User, Lock, Mail, Phone, MapPin, Sparkles, LogIn, UserPlus, Shield, LogOut } from 'lucide-react';
|
||||
import { useAuth } from '@/context/AuthContext';
|
||||
import { useBrand } from '@/context/BrandContext';
|
||||
import Link from 'next/link';
|
||||
|
||||
export const AuthModal: React.FC = () => {
|
||||
const { user, login, register, logout, isAuthModalOpen, closeAuthModal } = useAuth();
|
||||
const { currentBrand } = useBrand();
|
||||
|
||||
const [mode, setMode] = useState<'login' | 'register'>('login');
|
||||
const [identifier, setIdentifier] = useState('');
|
||||
const [name, setName] = useState('');
|
||||
const [email, setEmail] = useState('');
|
||||
const [phone, setPhone] = useState('');
|
||||
const [city, setCity] = useState('Cali');
|
||||
const [address, setAddress] = useState('');
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
if (!isAuthModalOpen) return null;
|
||||
|
||||
const handleLoginSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError(null);
|
||||
setLoading(true);
|
||||
const res = await login(identifier);
|
||||
setLoading(false);
|
||||
if (!res.success) {
|
||||
setError(res.error || 'Error al iniciar sesión');
|
||||
}
|
||||
};
|
||||
|
||||
const handleRegisterSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError(null);
|
||||
setLoading(true);
|
||||
const res = await register({ name, email, phone, city, address });
|
||||
setLoading(false);
|
||||
if (!res.success) {
|
||||
setError(res.error || 'Error al registrarse');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 bg-black/80 backdrop-blur-md flex items-center justify-center p-4 animate-in fade-in">
|
||||
<div className="bg-slate-900 border border-slate-800 rounded-3xl max-w-md w-full p-6 space-y-4 shadow-2xl relative">
|
||||
<button
|
||||
onClick={closeAuthModal}
|
||||
className="absolute top-4 right-4 text-slate-400 hover:text-white p-1 rounded-full cursor-pointer"
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
|
||||
{/* If user is already logged in: View Profile */}
|
||||
{user ? (
|
||||
<div className="space-y-4 text-center">
|
||||
<div className={`w-16 h-16 rounded-3xl bg-gradient-to-br ${currentBrand.colors.primaryGradient} flex items-center justify-center text-white font-black text-2xl mx-auto shadow-lg`}>
|
||||
{user.name.charAt(0).toUpperCase()}
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-black text-white text-lg">{user.name}</h3>
|
||||
<p className="text-xs text-slate-400">{user.email}</p>
|
||||
<div className="inline-block mt-1">
|
||||
<span className={`text-[10px] font-bold px-2.5 py-0.5 rounded-full border ${
|
||||
user.role === 'admin'
|
||||
? 'bg-purple-500/20 text-purple-300 border-purple-500/40'
|
||||
: user.role === 'operator'
|
||||
? 'bg-cyan-500/20 text-cyan-300 border-cyan-500/40'
|
||||
: 'bg-emerald-500/20 text-emerald-300 border-emerald-500/40'
|
||||
}`}>
|
||||
{user.role === 'admin' ? '👑 Administrador' : user.role === 'operator' ? '📦 Operador Logística' : '🛍️ Cliente Registrado'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-slate-950/80 border border-slate-800 rounded-2xl p-3 text-left text-xs space-y-1.5 text-slate-300">
|
||||
<p><strong>📱 Celular:</strong> {user.phone}</p>
|
||||
<p><strong>📍 Ciudad:</strong> {user.city || 'Cali'}</p>
|
||||
{user.address && <p><strong>🏠 Dirección:</strong> {user.address}</p>}
|
||||
</div>
|
||||
|
||||
{user.role === 'admin' || user.role === 'operator' ? (
|
||||
<Link
|
||||
href="/admin"
|
||||
onClick={closeAuthModal}
|
||||
className={`w-full bg-gradient-to-r ${currentBrand.colors.primaryGradient} text-white font-black text-xs py-3 rounded-xl flex items-center justify-center gap-1.5 shadow-md`}
|
||||
>
|
||||
<Shield className="w-4 h-4" />
|
||||
<span>Ir al Panel de Control Admin</span>
|
||||
</Link>
|
||||
) : null}
|
||||
|
||||
<button
|
||||
onClick={logout}
|
||||
className="w-full bg-slate-800 hover:bg-red-950/40 hover:border-red-500/50 border border-transparent text-slate-300 hover:text-red-300 font-bold text-xs py-2.5 rounded-xl flex items-center justify-center gap-1.5 cursor-pointer transition-all"
|
||||
>
|
||||
<LogOut className="w-4 h-4" />
|
||||
<span>Cerrar Sesión</span>
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
/* Login or Register */
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className={`w-9 h-9 rounded-xl bg-gradient-to-br ${currentBrand.colors.primaryGradient} flex items-center justify-center text-white font-black shadow-md`}>
|
||||
{currentBrand.shortName[0]}
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-black text-white text-base leading-tight">
|
||||
{mode === 'login' ? 'Iniciar Sesión' : 'Crear Cuenta'}
|
||||
</h3>
|
||||
<p className="text-xs text-slate-400">
|
||||
{mode === 'login'
|
||||
? 'Accede para gestionar pedidos o entrar como admin'
|
||||
: 'Regístrate para compras rápidas y seguimiento'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Mode Switch Tabs */}
|
||||
<div className="grid grid-cols-2 p-1 bg-slate-950 border border-slate-800 rounded-2xl">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { setMode('login'); setError(null); }}
|
||||
className={`text-xs font-bold py-2 rounded-xl transition-all cursor-pointer ${
|
||||
mode === 'login' ? 'bg-white text-slate-950 shadow' : 'text-slate-400 hover:text-white'
|
||||
}`}
|
||||
>
|
||||
Ingresar
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { setMode('register'); setError(null); }}
|
||||
className={`text-xs font-bold py-2 rounded-xl transition-all cursor-pointer ${
|
||||
mode === 'register' ? 'bg-white text-slate-950 shadow' : 'text-slate-400 hover:text-white'
|
||||
}`}
|
||||
>
|
||||
Registrarme
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="bg-red-950/80 border border-red-500 text-red-300 text-xs px-3.5 py-2.5 rounded-xl animate-in fade-in">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{mode === 'login' ? (
|
||||
<form onSubmit={handleLoginSubmit} className="space-y-3 text-xs">
|
||||
<div>
|
||||
<label className="block text-slate-400 font-bold mb-1">Correo Electrónico o Celular *</label>
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
placeholder="ej: carlos@almeira.com.co o 3134848203"
|
||||
value={identifier}
|
||||
onChange={(e) => setIdentifier(e.target.value)}
|
||||
className="w-full bg-slate-950 border border-slate-800 rounded-xl px-3 py-2.5 text-white outline-hidden focus:border-cyan-400"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className={`w-full bg-gradient-to-r ${currentBrand.colors.primaryGradient} text-white font-black py-3 rounded-xl shadow-lg flex items-center justify-center gap-1.5 cursor-pointer active:scale-95 transition-all`}
|
||||
>
|
||||
<LogIn className="w-4 h-4" />
|
||||
<span>{loading ? 'Accediendo...' : 'Iniciar Sesión'}</span>
|
||||
</button>
|
||||
|
||||
<p className="text-[11px] text-center text-slate-500">
|
||||
Tip Admin: Puedes ingresar con <strong>carlos@almeira.com.co</strong> o <strong>3134848203</strong>.
|
||||
</p>
|
||||
</form>
|
||||
) : (
|
||||
<form onSubmit={handleRegisterSubmit} className="space-y-3 text-xs">
|
||||
<div>
|
||||
<label className="block text-slate-400 font-bold mb-1">Nombre Completo *</label>
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
placeholder="ej: Andrés Morales"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
className="w-full bg-slate-950 border border-slate-800 rounded-xl px-3 py-2 text-white outline-hidden focus:border-cyan-400"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div>
|
||||
<label className="block text-slate-400 font-bold mb-1">Correo Electrónico *</label>
|
||||
<input
|
||||
type="email"
|
||||
required
|
||||
placeholder="andres@gmail.com"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
className="w-full bg-slate-950 border border-slate-800 rounded-xl px-3 py-2 text-white outline-hidden focus:border-cyan-400"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-slate-400 font-bold mb-1">Celular *</label>
|
||||
<input
|
||||
type="tel"
|
||||
required
|
||||
placeholder="3112345678"
|
||||
value={phone}
|
||||
onChange={(e) => setPhone(e.target.value)}
|
||||
className="w-full bg-slate-950 border border-slate-800 rounded-xl px-3 py-2 text-white outline-hidden focus:border-cyan-400"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div>
|
||||
<label className="block text-slate-400 font-bold mb-1">Ciudad</label>
|
||||
<input
|
||||
type="text"
|
||||
value={city}
|
||||
onChange={(e) => setCity(e.target.value)}
|
||||
className="w-full bg-slate-950 border border-slate-800 rounded-xl px-3 py-2 text-white outline-hidden focus:border-cyan-400"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-slate-400 font-bold mb-1">Dirección Entrega</label>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Calle 5 # 34-12"
|
||||
value={address}
|
||||
onChange={(e) => setAddress(e.target.value)}
|
||||
className="w-full bg-slate-950 border border-slate-800 rounded-xl px-3 py-2 text-white outline-hidden focus:border-cyan-400"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className={`w-full bg-gradient-to-r ${currentBrand.colors.primaryGradient} text-white font-black py-3 rounded-xl shadow-lg flex items-center justify-center gap-1.5 cursor-pointer active:scale-95 transition-all`}
|
||||
>
|
||||
<UserPlus className="w-4 h-4" />
|
||||
<span>{loading ? 'Creando cuenta...' : 'Crear mi Cuenta Gratis'}</span>
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||