feat(multistore): agregar dripzone, sneaky, zapply y almeira-store listos para produccion en coolify y appwrite

This commit is contained in:
AI Bot 2026-08-25 22:59:08 -05:00
parent bbce2a7f9a
commit 349e72839d
485 changed files with 77287 additions and 3 deletions

5
.gitignore vendored
View File

@ -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
View 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

View 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 -->

View File

@ -0,0 +1 @@
@AGENTS.md

View 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"]

View 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
View 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

View File

@ -0,0 +1,2 @@
/build/*
!/build/.npmkeep

View 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")
}

View 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()
}

View 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

View File

@ -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());
}
}

View 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>

View File

@ -0,0 +1,5 @@
package co.almeira.store;
import com.getcapacitor.BridgeActivity;
public class MainActivity extends BridgeActivity {}

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

View File

@ -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>

View File

@ -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>

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

View File

@ -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>

View File

@ -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>

View File

@ -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>

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

View File

@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="ic_launcher_background">#FFFFFF</color>
</resources>

View File

@ -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>

View 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>

View File

@ -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>

View File

@ -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);
}
}

View 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
}

View 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')

View 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

Binary file not shown.

View 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
View 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
View 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

View 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'

View 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'
}

View 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;

View 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;

View 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

File diff suppressed because it is too large Load Diff

View 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"
}
}

View File

@ -0,0 +1,7 @@
const config = {
plugins: {
"@tailwindcss/postcss": {},
},
};
export default config;

View 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

View 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

View 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

View 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"
}
]
}

View 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

View 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

View 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

View 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));

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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 (&gt;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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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 });
}
}

View 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 });
}
}

View 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 });
}
}

View 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 });
}
}

View 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 });
}
}

View 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 });
}
}

View 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 });
}
}

View 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 });
}
}

View 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 });
}
}

View 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 });
}
}

View 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 });
}
}

View 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>
);
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

View 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;
}

View 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>
);
}

View 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>
);
}

View 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>
);
};

Some files were not shown because too many files have changed in this diff Show More