Rename branding to JUNO, improve sync flows, and update docs
Made-with: Cursor
This commit is contained in:
parent
fadec2e06f
commit
ab14d7b004
2
.github/workflows/deploy-ec2.yml
vendored
2
.github/workflows/deploy-ec2.yml
vendored
@ -4,7 +4,7 @@ env:
|
|||||||
IMAGE_NAME: junohub-app
|
IMAGE_NAME: junohub-app
|
||||||
CONTAINER_NAME: junohub-app
|
CONTAINER_NAME: junohub-app
|
||||||
# Absolute directory on the EC2 instance where app + compose live
|
# Absolute directory on the EC2 instance where app + compose live
|
||||||
REMOTE_DIR: /home/ubuntu/JUNOHUB
|
REMOTE_DIR: /home/ubuntu/JUNO
|
||||||
|
|
||||||
on:
|
on:
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
|||||||
350
Documents/DETAIL.md
Normal file
350
Documents/DETAIL.md
Normal file
@ -0,0 +1,350 @@
|
|||||||
|
## Project Overview
|
||||||
|
|
||||||
|
This project JUNO is an **AI-powered communication hub for Shopify-based dropshipping businesses**.
|
||||||
|
It focuses on fixing the broken, slow, and inconsistent communication that happens:
|
||||||
|
- **From customer to vendor** (the buyer talking to the Shopify store).
|
||||||
|
- **From vendor to supplier** (the store owner coordinating with the inventory/fulfilment partner).
|
||||||
|
|
||||||
|
The goal is to **automate, standardize, and speed up** these conversations while keeping humans in control for sensitive or high‑risk cases. This document explains the real problems we see in today’s Shopify dropshipping workflows and how our solution addresses them end to end. It is intended as a **high-level narrative and feature breakdown** so a designer can understand the flows and design a clear, compelling standee around them.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## The Problem in Shopify Dropshipping
|
||||||
|
|
||||||
|
### 1. Fragmented and slow communication
|
||||||
|
- **Multiple channels**: Customers contact the vendor via email, Shopify chat, social media DMs, WhatsApp, etc., while the vendor talks to suppliers via email, WhatsApp groups, or proprietary portals.
|
||||||
|
- **No unified view**: A vendor often cannot see, in one place, the full story of an order: customer messages, supplier updates, tracking, refunds, and exceptions.
|
||||||
|
- **Slow response times**: Vendors manually read every message, look up order data in Shopify or in supplier dashboards, and then type replies. This causes:
|
||||||
|
- Delays in replying to customers.
|
||||||
|
- Delays in sending instructions to suppliers.
|
||||||
|
- Lost sales and poor customer experience.
|
||||||
|
|
||||||
|
### 2. High support load and repetitive questions
|
||||||
|
- Customers repeatedly ask:
|
||||||
|
- “Where is my order?”
|
||||||
|
- “Can I change my address?”
|
||||||
|
- “When will it ship?”
|
||||||
|
- “Can I return this item?”
|
||||||
|
- Vendors must **manually search orders, tracking links, and policies** to answer the same questions again and again.
|
||||||
|
- During peak periods (sales, holidays), messages spike and vendors cannot keep up, leading to:
|
||||||
|
- Unanswered tickets.
|
||||||
|
- Angry customers.
|
||||||
|
- Negative reviews and chargebacks.
|
||||||
|
|
||||||
|
### 3. Miscommunication between vendor and supplier
|
||||||
|
- Vendors depend on suppliers for:
|
||||||
|
- Stock availability and substitutions.
|
||||||
|
- Shipping timelines and carrier issues.
|
||||||
|
- Order modifications (cancellations, address changes, quantity updates).
|
||||||
|
- Returns, refunds, and exchanges.
|
||||||
|
- Today this is often handled via:
|
||||||
|
- Unstructured emails or chats.
|
||||||
|
- Messages without clear order references, SKUs, or deadlines.
|
||||||
|
- Language barriers and time zone differences.
|
||||||
|
- This leads to:
|
||||||
|
- Wrong items shipped.
|
||||||
|
- Late shipments.
|
||||||
|
- Failed cancellations or address changes.
|
||||||
|
- Confusion about responsibility for refunds or reships.
|
||||||
|
|
||||||
|
### 4. No intelligence or prioritization
|
||||||
|
- All messages look the same in a generic inbox.
|
||||||
|
- **Critical messages** (fraud risk, legal threats, VIP customers, high‑value orders, very angry customers) are not automatically prioritized.
|
||||||
|
- Vendors lack:
|
||||||
|
- Automated sentiment analysis.
|
||||||
|
- Intent detection (e.g., “refund request” vs “simple question”).
|
||||||
|
- Smart routing/escalation (e.g., “send this to a human immediately”).
|
||||||
|
|
||||||
|
### 5. Lack of consistency and brand tone
|
||||||
|
- Different team members reply with different wording and policies.
|
||||||
|
- Emotional messages from customers or suppliers may receive emotional, inconsistent responses.
|
||||||
|
- There is no system to:
|
||||||
|
- Enforce tone of voice.
|
||||||
|
- Ensure policy‑correct, legally safe replies.
|
||||||
|
- Reuse what worked well in previous successful resolutions.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Our Solution Overview
|
||||||
|
|
||||||
|
We introduce a **two-sided AI communication system** tightly integrated with Shopify and the vendor’s suppliers:
|
||||||
|
|
||||||
|
- **Side 1: Customer → Vendor**
|
||||||
|
- A dedicated AI assistant reads customer messages, understands intent and mood, fetches data from the database (orders, tracking, policies), and proposes or sends consistent replies on behalf of the vendor.
|
||||||
|
|
||||||
|
- **Side 2: Vendor → Supplier**
|
||||||
|
- A second AI assistant reads vendor-side instructions and system events, understands what must be communicated to suppliers, and generates structured, clear messages into a **dedicated vendor–supplier chat system** named **JUNOCHAT**.
|
||||||
|
|
||||||
|
Both sides use **trained AI models** tailored to dropshipping language, policies, and edge cases, with human override options and safety checks.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Customer → Vendor Communication
|
||||||
|
|
||||||
|
### 1. Inputs and data sources
|
||||||
|
For each customer message, our system has access to:
|
||||||
|
- **Customer message content** (email, chat, social DM, etc.).
|
||||||
|
- **Shopify data**:
|
||||||
|
- Orders linked by email, phone, name, or order ID.
|
||||||
|
- Order status (paid, fulfilled, in transit, delivered).
|
||||||
|
- Tracking information from carriers.
|
||||||
|
- **Internal policies**:
|
||||||
|
- Refund and return rules.
|
||||||
|
- Shipping times and regions.
|
||||||
|
- Discount and compensation limits.
|
||||||
|
- **Knowledge base**:
|
||||||
|
- Product FAQs.
|
||||||
|
- Size guides, material descriptions, usage instructions.
|
||||||
|
|
||||||
|
The system automatically links the incoming message with the most relevant order(s) and context.
|
||||||
|
|
||||||
|
### 2. AI model behavior (customer side)
|
||||||
|
We use a **trained model** that:
|
||||||
|
- **Detects intent**:
|
||||||
|
- Examples: pre‑purchase question, order status request, address change, refund request, cancellation request, complaint, product issue, technical question, generic feedback, spam, etc.
|
||||||
|
- **Detects mood/sentiment**:
|
||||||
|
- Examples: happy, neutral, confused, frustrated, angry, urgent, threatening (legal/chargeback).
|
||||||
|
- **Understands multi‑turn context**:
|
||||||
|
- Reads past messages in the same thread.
|
||||||
|
- Respects previous promises made to the customer.
|
||||||
|
|
||||||
|
Based on intent + mood + context, the model:
|
||||||
|
- Fetches required data from the **database** (orders, tracking, policy rules).
|
||||||
|
- Generates a **draft response** in the vendor’s tone of voice.
|
||||||
|
- Ensures:
|
||||||
|
- Policy compliance (e.g., does not promise unauthorized full refunds).
|
||||||
|
- Factual accuracy based on live data (e.g., correct tracking status).
|
||||||
|
|
||||||
|
The response can be:
|
||||||
|
- **Automatically sent** for low‑risk, common cases (e.g., standard “where is my order”).
|
||||||
|
- **Queued for human review** for higher‑risk or unusual cases (e.g., legal threats, high cart value, repeated complaints).
|
||||||
|
|
||||||
|
### 3. Key features for the designer to highlight
|
||||||
|
- **Unified inbox**: All customer messages from multiple channels appear in one place, enriched with order data.
|
||||||
|
- **Intent + mood labels**: Each conversation is tagged (e.g., “Refund request – Angry – High priority”).
|
||||||
|
- **Smart reply suggestions**: The AI proposes ready‑to‑send answers, saving the vendor time.
|
||||||
|
- **Auto‑actions**:
|
||||||
|
- Automatically includes links, tracking numbers, or return instructions.
|
||||||
|
- Automatically asks clarifying questions when essential data is missing (e.g., order ID).
|
||||||
|
- **Brand‑consistent tone**: Replies follow the store’s style (friendly, professional, etc.).
|
||||||
|
|
||||||
|
### 4. Edge cases we handle (customer side)
|
||||||
|
- **No matching order found**:
|
||||||
|
- The model asks for additional information (order ID, email) instead of guessing.
|
||||||
|
- **Multiple possible orders for the same customer**:
|
||||||
|
- The system clarifies which order the customer is asking about or summarises all relevant orders clearly.
|
||||||
|
- **Angry or abusive messages**:
|
||||||
|
- Calm, de‑escalating tone.
|
||||||
|
- Option to auto‑escalate to a human.
|
||||||
|
- Ability to flag for potential fraud or chargeback risk.
|
||||||
|
- **Language differences**:
|
||||||
|
- Automatically detects language and responds in the customer’s language (where configured).
|
||||||
|
- **Complex policy cases** (e.g., partial refunds, used products, out‑of‑policy requests):
|
||||||
|
- AI applies rules and, if unsure, marks for human approval rather than auto‑committing.
|
||||||
|
- **Time‑critical changes** (cancellation/address change before fulfilment):
|
||||||
|
- High‑urgency flag.
|
||||||
|
- Passes structured instructions into the vendor → supplier flow (see next section).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Vendor → Supplier Communication
|
||||||
|
|
||||||
|
### 1. Core idea
|
||||||
|
The **vendor → supplier side** ensures that what the customer needs (refunds, reships, changes, information) is translated into **clear, actionable, and timely instructions for suppliers**.
|
||||||
|
|
||||||
|
Instead of the vendor writing ad‑hoc emails or chat messages, our system:
|
||||||
|
- Understands the **vendor’s intent and mood**.
|
||||||
|
- Converts that into a **structured, standardized message** to the supplier.
|
||||||
|
- Sends it via a **dedicated vendor–supplier chat system** or the supplier’s preferred channel/integration.
|
||||||
|
|
||||||
|
### 2. Inputs and data sources
|
||||||
|
For each required vendor → supplier communication, we combine:
|
||||||
|
- **Vendor actions and messages** inside our system:
|
||||||
|
- Clicking actions like “request cancellation”, “request reship”, “ask for stock confirmation”.
|
||||||
|
- Manual notes or additional comments by the vendor.
|
||||||
|
- **Customer side context**:
|
||||||
|
- What the customer requested.
|
||||||
|
- How urgent or time‑sensitive the situation is.
|
||||||
|
- Any promises already made to the customer.
|
||||||
|
- **Order and product data**:
|
||||||
|
- Order ID, SKUs, quantities, prices, shipping method.
|
||||||
|
- Tracking IDs and previous supplier updates.
|
||||||
|
- **Supplier configuration**:
|
||||||
|
- Time zone and typical response speed.
|
||||||
|
- Preferred language.
|
||||||
|
- Business rules (MOQs, restocking times, RMA process).
|
||||||
|
|
||||||
|
### 3. AI model behavior (vendor side)
|
||||||
|
We use a **separate AI model** specialized in:
|
||||||
|
- **Vendor intent + mood detection**:
|
||||||
|
- Examples: stock inquiry, urgent cancellation, change of address, reshipment request, quality complaint, bulk pricing discussion, general update.
|
||||||
|
- Mood: calm, neutral, urgent, frustrated, high‑severity.
|
||||||
|
- **Message generation to suppliers**:
|
||||||
|
- Converts intent into a clear message that contains:
|
||||||
|
- All necessary reference IDs.
|
||||||
|
- Clear requests and deadlines.
|
||||||
|
- Structured bullet points for actions.
|
||||||
|
- Uses suitable tone (firm but professional), regardless of the vendor’s emotional state.
|
||||||
|
- **Chat system integration**:
|
||||||
|
- Automatically posts the generated message into the **dedicated vendor–supplier chat** or connected supplier system.
|
||||||
|
- Organizes conversations per order or ticket so everyone sees complete history.
|
||||||
|
|
||||||
|
### 4. Key features for the designer to highlight
|
||||||
|
- **Dedicated vendor–supplier chat**:
|
||||||
|
- One place to see all messages to and from each supplier, grouped by order.
|
||||||
|
- **Automatic message drafting**:
|
||||||
|
- Vendor clicks an action (e.g., “request cancellation”) and the system drafts a supplier message with all required details pre‑filled.
|
||||||
|
- **Structured, unambiguous supplier requests**:
|
||||||
|
- No more vague messages like “pls cancel”; instead, clear instructions listing:
|
||||||
|
- Order details.
|
||||||
|
- Items affected.
|
||||||
|
- Requested action (cancel, reship, refund, investigation).
|
||||||
|
- Time constraints.
|
||||||
|
- **Two‑way visibility**:
|
||||||
|
- Supplier replies are pulled back into the vendor’s system.
|
||||||
|
- Customer‑facing side is updated based on supplier confirmations (e.g., updated ETAs, reship tracking).
|
||||||
|
|
||||||
|
### 5. Edge cases we handle (vendor side)
|
||||||
|
- **Multiple suppliers per order**:
|
||||||
|
- The system splits the instructions appropriately and sends them to each relevant supplier.
|
||||||
|
- **Backorders and out‑of‑stock situations**:
|
||||||
|
- Supplier response is interpreted by the AI and reflected back into customer‑facing options (e.g., refund vs alternative product).
|
||||||
|
- **No response from supplier**:
|
||||||
|
- Automated reminders after configurable time windows.
|
||||||
|
- Escalation markers for the vendor.
|
||||||
|
- **Language and time zone differences**:
|
||||||
|
- Messages can be localized; scheduling considers supplier local hours.
|
||||||
|
- **Conflicting information**:
|
||||||
|
- If supplier information contradicts existing tracking or data, the system flags it for human review rather than auto‑resolving.
|
||||||
|
- **Policy alignment**:
|
||||||
|
- Ensures requests sent to suppliers remain aligned with vendor’s commitment to customers and legal guidelines.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## End-to-End Flow: From Customer Message to Supplier Action
|
||||||
|
|
||||||
|
At a high level, the journey works like this:
|
||||||
|
1. **Customer writes a message** (e.g., “I used the wrong address, please change it”).
|
||||||
|
2. The **customer-side AI model**:
|
||||||
|
- Detects intent (address change) and mood (worried/urgent).
|
||||||
|
- Fetches order data and checks fulfilment status.
|
||||||
|
3. If the order is not yet shipped:
|
||||||
|
- The AI replies to the customer, acknowledging the request and explaining the next steps.
|
||||||
|
- At the same time, it triggers a **vendor → supplier action** to update the address.
|
||||||
|
4. The **vendor-side AI model**:
|
||||||
|
- Crafts a precise update request to the supplier with the new address and delivery instructions.
|
||||||
|
- Sends it through the dedicated vendor–supplier chat.
|
||||||
|
5. **Supplier responds** (e.g., “address updated successfully” or “too late, package shipped”).
|
||||||
|
6. The system:
|
||||||
|
- Updates internal status.
|
||||||
|
- Notifies the customer with an accurate outcome, again via the customer-side AI assistant.
|
||||||
|
|
||||||
|
Throughout this flow:
|
||||||
|
- **All actions are logged and auditable**.
|
||||||
|
- **Humans can override or adjust AI decisions**.
|
||||||
|
- **Customers receive quick, consistent, data‑driven replies**.
|
||||||
|
- **Suppliers receive clear, actionable instructions**, reducing errors and back‑and‑forth.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Vendor Onboarding Journey
|
||||||
|
|
||||||
|
This section explains the **actual onboarding flow implemented in JUNO** for a new vendor, based directly on the signup screens, onboarding wizard, and backend routes.
|
||||||
|
|
||||||
|
### Step 0: Create a Vendor Account
|
||||||
|
- The user goes to the signup page and chooses the **VENDOR** role (or signs in with Google where the callback leads to onboarding).
|
||||||
|
- After registration and login:
|
||||||
|
- If the user is a vendor **without any store that has `onboardingComplete = true`**, they are sent to the **multi‑step onboarding wizard** at the `/onboarding` route.
|
||||||
|
- If they already have at least one fully onboarded store, they are redirected straight to the **dashboard** instead of seeing onboarding again.
|
||||||
|
|
||||||
|
### Step 1: Business Profile (Business Info)
|
||||||
|
- In the first step of the onboarding wizard, the vendor fills in basic business details:
|
||||||
|
- **Business name** (public name of the brand).
|
||||||
|
- **Business email** (main contact address).
|
||||||
|
- **Country**.
|
||||||
|
- **Timezone**.
|
||||||
|
- This information is stored with the store record and is used to:
|
||||||
|
- Pre‑fill communication details.
|
||||||
|
- Align timestamps and reporting with the vendor’s local time.
|
||||||
|
|
||||||
|
### Step 2: Connect Shopify (Store + Credentials)
|
||||||
|
- In the second step, the vendor connects their **Shopify store** by entering:
|
||||||
|
- **Shopify store name**.
|
||||||
|
- **Shopify domain** (e.g., `mystore.myshopify.com`, which the backend cleans and normalizes).
|
||||||
|
- **Admin API Key**.
|
||||||
|
- **Admin API Secret**.
|
||||||
|
- **Admin Access Token**.
|
||||||
|
- When the vendor clicks the validation button:
|
||||||
|
- The client calls the **Shopify validation endpoint** with these values.
|
||||||
|
- If required fields (domain and access token) are missing or the credentials are invalid, the UI shows a clear error and **does not proceed to the next step**.
|
||||||
|
- On success, the wizard moves the vendor forward, ensuring that only a verified Shopify connection continues in the flow.
|
||||||
|
|
||||||
|
### Step 3: Sync & Fulfilment Preferences
|
||||||
|
- In the third step, the vendor configures preferences that control how JUNO interacts with Shopify and suppliers:
|
||||||
|
- **Sync frequency** for pulling Shopify data (options like real‑time, hourly, or daily).
|
||||||
|
- A toggle for **automated fulfilment** (whether new orders should be automatically pushed to suppliers).
|
||||||
|
- These options become part of the onboarding payload so that, when the account is finalized, the backend knows:
|
||||||
|
- How aggressively to sync data.
|
||||||
|
- Whether to immediately trigger supplier workflows for new orders.
|
||||||
|
|
||||||
|
### Step 4: Finalize and Persist Onboarding
|
||||||
|
- In the final step, the vendor submits all onboarding data:
|
||||||
|
- The frontend sends the collected fields (business info, Shopify details, preferences) to the **`/api/onboarding/complete`** endpoint.
|
||||||
|
- On the backend:
|
||||||
|
- The system checks authentication and ensures the user is valid.
|
||||||
|
- It validates the critical fields (e.g., business name, domain, access token).
|
||||||
|
- It **cleans the Shopify domain** (removing protocol, trailing slashes, and whitespace).
|
||||||
|
- It then either:
|
||||||
|
- **Updates an existing store** for this user + domain, or
|
||||||
|
- **Creates a new store record** if one does not exist yet.
|
||||||
|
- The store is saved with:
|
||||||
|
- Business name + email.
|
||||||
|
- Country + timezone (or sensible defaults).
|
||||||
|
- Shopify store name + normalized domain.
|
||||||
|
- Admin API key, secret, and access token.
|
||||||
|
- Onboarding flags:
|
||||||
|
- `onboardingComplete = true`.
|
||||||
|
- `isActive` set for the first store.
|
||||||
|
- If this is the **first store** for the vendor:
|
||||||
|
- The user’s `activeStoreId` is updated to point to this new store.
|
||||||
|
- The backend also **registers Shopify webhooks** for events such as:
|
||||||
|
- `orders/create` and `orders/updated`.
|
||||||
|
- `products/create`, `products/update`, `products/delete`.
|
||||||
|
- These webhooks are pointed to the correct app URL so JUNO can receive live updates from Shopify.
|
||||||
|
- Once the API call succeeds:
|
||||||
|
- The UI shows a **“Setup Complete”** screen.
|
||||||
|
- The vendor is then redirected to the **dashboard**, where syncing and AI‑driven workflows take over.
|
||||||
|
|
||||||
|
### Multi‑Store and Onboarding Checks
|
||||||
|
- A vendor can have **multiple Shopify stores** under one JUNO account:
|
||||||
|
- Each store is stored separately with its own domain, credentials, and `onboardingComplete` flag.
|
||||||
|
- Additional store connections use a streamlined Shopify onboarding flow that collects the same core fields and persists another store record.
|
||||||
|
- The **`/api/onboarding/check`** endpoint is used to:
|
||||||
|
- Confirm if a vendor already has at least one fully onboarded store.
|
||||||
|
- Skip the onboarding wizard for non‑vendor roles (e.g., suppliers) by immediately marking them as complete.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## What the Designer Should Take Away
|
||||||
|
|
||||||
|
- **Core message**: We fix broken communication in Shopify dropshipping by using AI on both sides:
|
||||||
|
- **Customer → Vendor**: Intelligent support assistant for customer messages.
|
||||||
|
- **Vendor → Supplier**: Intelligent coordination assistant for supplier communication.
|
||||||
|
- **Vendor Onboarding**: A concrete, four‑step wizard (business profile → Shopify connection → sync/fulfilment preferences → finalize) backed by real API endpoints and webhook setup.
|
||||||
|
- **Key benefits to visualize**:
|
||||||
|
- Faster replies, fewer mistakes, less manual typing.
|
||||||
|
- Happier customers (clear answers, faster resolutions).
|
||||||
|
- Happier vendors (less support load, more control).
|
||||||
|
- Happier suppliers (clear, structured instructions).
|
||||||
|
- **Feel of the product**:
|
||||||
|
- Central, unified communication hub.
|
||||||
|
- AI co‑pilot on both ends of the chain.
|
||||||
|
- Human‑in‑the‑loop safety for sensitive cases.
|
||||||
|
- Smooth onboarding path from day one to full automation.
|
||||||
|
|
||||||
|
This document should provide enough depth to design a standee that clearly shows:
|
||||||
|
- The **two main flows** (customer → vendor and vendor → supplier).
|
||||||
|
- The **vendor onboarding journey** from connect → configure → automate.
|
||||||
|
- The **before vs. after** pain points and improvements.
|
||||||
|
- The **AI‑powered, data‑aware, and automated nature** of the solution.
|
||||||
|
|
||||||
@ -1,164 +0,0 @@
|
|||||||
# Gmail Integration Setup Guide
|
|
||||||
|
|
||||||
This guide explains how to set up Gmail integration so that **every vendor can connect their own Gmail account** to receive and send customer emails.
|
|
||||||
|
|
||||||
## How It Works
|
|
||||||
|
|
||||||
- **One OAuth App**: You create ONE Google OAuth application in Google Cloud Console
|
|
||||||
- **Shared Credentials**: All vendors use the same `GOOGLE_CLIENT_ID` and `GOOGLE_CLIENT_SECRET`
|
|
||||||
- **Individual Authorization**: Each vendor authorizes their own Gmail account when connecting
|
|
||||||
- **Separate Tokens**: Each vendor's Gmail tokens are stored separately in the database
|
|
||||||
|
|
||||||
## Login Method vs Gmail Integration
|
|
||||||
|
|
||||||
**IMPORTANT**: These are TWO separate things:
|
|
||||||
|
|
||||||
1. **Login Method** (How vendor logs into YOUR app):
|
|
||||||
- Email/Password login ✅
|
|
||||||
- Google OAuth login ✅
|
|
||||||
- Both work independently
|
|
||||||
|
|
||||||
2. **Gmail Integration** (Authorizing Gmail API access):
|
|
||||||
- Separate OAuth flow
|
|
||||||
- Works regardless of login method
|
|
||||||
- Vendor can connect ANY Gmail account (doesn't need to match login email)
|
|
||||||
|
|
||||||
### Example Scenarios:
|
|
||||||
|
|
||||||
**Scenario 1**: Vendor logs in with email/password (`talatfaheem2@gmail.com`)
|
|
||||||
- ✅ Can connect Gmail integration
|
|
||||||
- When clicking "Connect Gmail", they'll be redirected to Google OAuth
|
|
||||||
- Can authorize the same Gmail (`talatfaheem2@gmail.com`) OR a different one (`business@gmail.com`)
|
|
||||||
|
|
||||||
**Scenario 2**: Vendor logs in with Google OAuth (`talatfaheem2@gmail.com`)
|
|
||||||
- ✅ Can connect Gmail integration
|
|
||||||
- When clicking "Connect Gmail", they'll be redirected to Google OAuth again
|
|
||||||
- Can authorize the same Gmail (`talatfaheem2@gmail.com`) OR a different one (`business@gmail.com`)
|
|
||||||
|
|
||||||
**Key Point**: The Gmail account they authorize doesn't need to match their login email!
|
|
||||||
|
|
||||||
## Step-by-Step Setup
|
|
||||||
|
|
||||||
### 1. Create Google Cloud Project (One Time Setup)
|
|
||||||
|
|
||||||
1. Go to [Google Cloud Console](https://console.cloud.google.com/)
|
|
||||||
2. Create a new project (or select existing)
|
|
||||||
3. Enable **Gmail API**:
|
|
||||||
- Go to [APIs & Services > Library](https://console.cloud.google.com/apis/library)
|
|
||||||
- Search for "Gmail API"
|
|
||||||
- Click "Enable"
|
|
||||||
|
|
||||||
### 2. Create OAuth 2.0 Credentials
|
|
||||||
|
|
||||||
1. Go to [APIs & Services > Credentials](https://console.cloud.google.com/apis/credentials)
|
|
||||||
2. Click "Create Credentials" > "OAuth client ID"
|
|
||||||
3. **If prompted**, configure OAuth consent screen (Google will prompt you the first time):
|
|
||||||
- Choose "External" (unless you have Google Workspace)
|
|
||||||
- Fill in app name, support email, developer contact
|
|
||||||
- **Note**: You don't need to manually add scopes here - they're automatically requested when vendors connect
|
|
||||||
- **IMPORTANT**: Add test users - Add ALL Gmail addresses that will connect (e.g., `krypttech4@gmail.com`, `talatfaheem2@gmail.com`, etc.)
|
|
||||||
- Click "Save and Continue" through the steps
|
|
||||||
|
|
||||||
**OR if consent screen is already configured:**
|
|
||||||
- Go to [APIs & Services > OAuth consent screen](https://console.cloud.google.com/apis/credentials/consent)
|
|
||||||
- Scroll down to "Test users" section
|
|
||||||
- Click "+ ADD USERS"
|
|
||||||
- Add the Gmail addresses that will connect (one per line or comma-separated)
|
|
||||||
- Click "Add"
|
|
||||||
4. Create OAuth client:
|
|
||||||
- Application type: "Web application"
|
|
||||||
- Name: "JunoHub Gmail Integration" (or your choice)
|
|
||||||
- **Authorized redirect URIs** (CRITICAL - must match exactly):
|
|
||||||
```
|
|
||||||
http://localhost:3000/api/integrations/gmail/callback
|
|
||||||
https://your-production-domain.com/api/integrations/gmail/callback
|
|
||||||
```
|
|
||||||
- If using ngrok: `https://your-ngrok-url.ngrok-free.dev/api/integrations/gmail/callback`
|
|
||||||
- **Important**: Add ALL environments you'll use (dev, staging, production, ngrok URLs)
|
|
||||||
|
|
||||||
### 3. Configure Environment Variables
|
|
||||||
|
|
||||||
Add to your `.env` file:
|
|
||||||
|
|
||||||
```env
|
|
||||||
# Your Google Cloud OAuth credentials (shared for all vendors)
|
|
||||||
GOOGLE_CLIENT_ID="your-client-id-here.apps.googleusercontent.com"
|
|
||||||
GOOGLE_CLIENT_SECRET="your-client-secret-here"
|
|
||||||
|
|
||||||
# Your app URL (used for OAuth redirects)
|
|
||||||
NEXTAUTH_URL="http://localhost:3000" # or your production URL
|
|
||||||
```
|
|
||||||
|
|
||||||
### 4. Test the Integration
|
|
||||||
|
|
||||||
1. Start your application
|
|
||||||
2. Log in as a vendor
|
|
||||||
3. Go to Juno Engine > Gmail Connect
|
|
||||||
4. Click "Connect Gmail Account"
|
|
||||||
5. Sign in with **any Gmail account** (doesn't need to match the OAuth app owner)
|
|
||||||
6. Authorize the requested permissions
|
|
||||||
7. You should be redirected back and see "Gmail Connected"
|
|
||||||
|
|
||||||
## Troubleshooting
|
|
||||||
|
|
||||||
### Error: `redirect_uri_mismatch`
|
|
||||||
|
|
||||||
**Problem**: The redirect URI in your code doesn't match what's configured in Google Cloud Console.
|
|
||||||
|
|
||||||
**Solution**:
|
|
||||||
1. Check the error message - it will show the redirect URI being used
|
|
||||||
2. Go to Google Cloud Console > Credentials > Your OAuth Client
|
|
||||||
3. Add the exact redirect URI shown in the error to "Authorized redirect URIs"
|
|
||||||
4. Wait a few minutes for changes to propagate
|
|
||||||
5. Try again
|
|
||||||
|
|
||||||
### Error: `invalid_client`
|
|
||||||
|
|
||||||
**Problem**: The `GOOGLE_CLIENT_ID` or `GOOGLE_CLIENT_SECRET` is incorrect.
|
|
||||||
|
|
||||||
**Solution**:
|
|
||||||
1. Double-check your `.env` file
|
|
||||||
2. Make sure there are no extra spaces or quotes
|
|
||||||
3. Restart your application after changing `.env`
|
|
||||||
|
|
||||||
### Error: `access_denied` or "Access blocked: has not completed the Google verification process"
|
|
||||||
|
|
||||||
**Problem**: The OAuth app is in "Testing" mode and the user's Gmail address is not added as a test user.
|
|
||||||
|
|
||||||
**Solution**:
|
|
||||||
1. Go to [APIs & Services > OAuth consent screen](https://console.cloud.google.com/apis/credentials/consent)
|
|
||||||
2. Scroll down to "Test users" section
|
|
||||||
3. Click "+ ADD USERS"
|
|
||||||
4. Add the Gmail address that's trying to connect (e.g., `krypttech4@gmail.com`)
|
|
||||||
5. Click "Add"
|
|
||||||
6. Wait a few minutes for changes to propagate
|
|
||||||
7. Try connecting again
|
|
||||||
|
|
||||||
**Note**: In testing mode, you must add each Gmail address as a test user. For production, you'll need to publish your app (requires Google verification).
|
|
||||||
|
|
||||||
## Important Notes
|
|
||||||
|
|
||||||
- **OAuth Consent Screen**: Google will prompt you to configure this the FIRST time you create OAuth credentials. After that, you can skip it. The scopes are automatically requested in the OAuth flow - you don't need to manually add them in the consent screen.
|
|
||||||
- **OAuth App Owner**: The email that created the OAuth app (`i221735@nu.edu.pk`) doesn't matter. Any Gmail account can authorize access.
|
|
||||||
- **Vendor Gmail**: Each vendor connects their own Gmail account (`talatfaheem2@gmail.com`, etc.). They don't need their own OAuth credentials.
|
|
||||||
- **Redirect URI**: Must match EXACTLY (including protocol, domain, port, path). No trailing slashes.
|
|
||||||
- **Multiple Environments**: Add redirect URIs for all environments (localhost, staging, production, ngrok).
|
|
||||||
|
|
||||||
## Security Considerations
|
|
||||||
|
|
||||||
- OAuth tokens are stored securely in the database
|
|
||||||
- Each vendor's tokens are isolated (can't access each other's emails)
|
|
||||||
- Tokens are automatically refreshed when expired
|
|
||||||
- Vendors can disconnect their Gmail at any time
|
|
||||||
|
|
||||||
## Production Checklist
|
|
||||||
|
|
||||||
- [ ] Gmail API enabled in Google Cloud Console
|
|
||||||
- [ ] OAuth consent screen configured
|
|
||||||
- [ ] **Test users added** (for testing mode) - Add all Gmail addresses that will connect
|
|
||||||
- [ ] OAuth client created with correct redirect URIs
|
|
||||||
- [ ] Environment variables set (`GOOGLE_CLIENT_ID`, `GOOGLE_CLIENT_SECRET`, `NEXTAUTH_URL`)
|
|
||||||
- [ ] Database migration run (`prisma db push`)
|
|
||||||
- [ ] Tested with at least one vendor account
|
|
||||||
- [ ] Production redirect URI added to Google Cloud Console
|
|
||||||
- [ ] **For production**: Publish your app (requires Google verification) OR keep adding test users as needed
|
|
||||||
@ -1,154 +0,0 @@
|
|||||||
# Gmail Webhook Setup Guide
|
|
||||||
|
|
||||||
This guide explains how to set up automatic email detection and AI-powered replies using Gmail push notifications.
|
|
||||||
|
|
||||||
## Overview
|
|
||||||
|
|
||||||
When a customer sends an email to your Gmail account, Gmail will send a push notification to our webhook endpoint. The webhook then:
|
|
||||||
1. Fetches the incoming email
|
|
||||||
2. Analyzes it using your store's knowledge base
|
|
||||||
3. Generates an AI-powered reply
|
|
||||||
4. Sends the reply automatically
|
|
||||||
|
|
||||||
## Prerequisites
|
|
||||||
|
|
||||||
1. **Gmail Integration**: Your Gmail account must be connected via `/dashboard/juno-engine/gmail-connect`
|
|
||||||
2. **Knowledge Base**: Your store must have FAQs or documents in the knowledge base
|
|
||||||
3. **Google Cloud Pub/Sub**: Required for Gmail push notifications
|
|
||||||
|
|
||||||
## Step 1: Set Up Google Cloud Pub/Sub
|
|
||||||
|
|
||||||
Gmail push notifications require Google Cloud Pub/Sub. Follow these steps:
|
|
||||||
|
|
||||||
### 1.1 Create a Pub/Sub Topic
|
|
||||||
|
|
||||||
1. Go to [Google Cloud Console](https://console.cloud.google.com/)
|
|
||||||
2. Navigate to **Pub/Sub** > **Topics**
|
|
||||||
3. Click **Create Topic**
|
|
||||||
4. Name it `gmail-notifications` (or any name you prefer)
|
|
||||||
5. Click **Create**
|
|
||||||
|
|
||||||
### 1.2 Create a Subscription
|
|
||||||
|
|
||||||
1. In the topic page, click **Create Subscription**
|
|
||||||
2. Name it `gmail-webhook-subscription`
|
|
||||||
3. Choose **Push** delivery type
|
|
||||||
4. Set the **Endpoint URL** to: `https://yourdomain.com/api/webhooks/gmail`
|
|
||||||
- Replace `yourdomain.com` with your actual domain
|
|
||||||
- For local development with ngrok: `https://your-ngrok-url.ngrok-free.dev/api/webhooks/gmail`
|
|
||||||
5. Click **Create**
|
|
||||||
|
|
||||||
### 1.3 Grant Gmail API Access
|
|
||||||
|
|
||||||
1. Go to **IAM & Admin** > **Service Accounts**
|
|
||||||
2. Find or create a service account for Gmail API
|
|
||||||
3. Grant it the `pubsub.topics.publish` permission on your topic
|
|
||||||
|
|
||||||
### 1.4 Get Topic Name
|
|
||||||
|
|
||||||
Copy the full topic name in the format:
|
|
||||||
```
|
|
||||||
projects/YOUR_PROJECT_ID/topics/gmail-notifications
|
|
||||||
```
|
|
||||||
|
|
||||||
## Step 2: Configure Environment Variables
|
|
||||||
|
|
||||||
Add the Pub/Sub topic name to your `.env` file:
|
|
||||||
|
|
||||||
```env
|
|
||||||
GMAIL_PUBSUB_TOPIC=projects/YOUR_PROJECT_ID/topics/gmail-notifications
|
|
||||||
```
|
|
||||||
|
|
||||||
## Step 3: Enable Gmail Watch
|
|
||||||
|
|
||||||
Once Pub/Sub is set up, enable Gmail push notifications:
|
|
||||||
|
|
||||||
### Option A: Via API
|
|
||||||
|
|
||||||
Make a POST request to:
|
|
||||||
```
|
|
||||||
POST /api/integrations/gmail/watch
|
|
||||||
```
|
|
||||||
|
|
||||||
This will set up Gmail to send push notifications to your webhook.
|
|
||||||
|
|
||||||
### Option B: Via UI (Recommended)
|
|
||||||
|
|
||||||
Add a button in the Gmail connect page to enable webhooks (coming soon).
|
|
||||||
|
|
||||||
## Step 4: Verify Webhook
|
|
||||||
|
|
||||||
Gmail will send a verification request to your webhook endpoint. The endpoint automatically handles verification by returning the challenge token.
|
|
||||||
|
|
||||||
## How It Works
|
|
||||||
|
|
||||||
### Webhook Flow
|
|
||||||
|
|
||||||
1. **Customer sends email** → Gmail receives it
|
|
||||||
2. **Gmail sends push notification** → Webhook endpoint receives notification
|
|
||||||
3. **Webhook processes email**:
|
|
||||||
- Fetches the email content
|
|
||||||
- Extracts sender, subject, and body
|
|
||||||
- Searches knowledge base for relevant information
|
|
||||||
- Generates AI-powered reply using Bedrock
|
|
||||||
4. **Reply is sent** → Customer receives automated response
|
|
||||||
5. **Email is marked as read** → Prevents duplicate processing
|
|
||||||
|
|
||||||
### Email Processing Logic
|
|
||||||
|
|
||||||
- Only processes emails from external senders (not same domain)
|
|
||||||
- Uses knowledge base to find relevant answers
|
|
||||||
- Generates contextual replies using AI
|
|
||||||
- Maintains email threading (replies are properly threaded)
|
|
||||||
- Logs all sent emails for tracking
|
|
||||||
|
|
||||||
## Monitoring
|
|
||||||
|
|
||||||
Check logs for:
|
|
||||||
- Webhook notifications received
|
|
||||||
- Emails processed
|
|
||||||
- Replies sent
|
|
||||||
- Any errors during processing
|
|
||||||
|
|
||||||
## Troubleshooting
|
|
||||||
|
|
||||||
### Webhook Not Receiving Notifications
|
|
||||||
|
|
||||||
1. Verify Pub/Sub topic exists and is configured correctly
|
|
||||||
2. Check that Gmail watch is active (watch expires after 7 days)
|
|
||||||
3. Verify webhook URL is accessible (not behind firewall)
|
|
||||||
4. Check Google Cloud Console logs for Pub/Sub errors
|
|
||||||
|
|
||||||
### Replies Not Being Sent
|
|
||||||
|
|
||||||
1. Verify Gmail integration is connected
|
|
||||||
2. Check that store has active knowledge base content
|
|
||||||
3. Review error logs for specific issues
|
|
||||||
4. Ensure Bedrock API credentials are configured
|
|
||||||
|
|
||||||
### Watch Expired
|
|
||||||
|
|
||||||
Gmail watch requests expire after 7 days. You need to renew them:
|
|
||||||
- Call `/api/integrations/gmail/watch` again
|
|
||||||
- Or set up a cron job to renew automatically
|
|
||||||
|
|
||||||
## Security
|
|
||||||
|
|
||||||
- Webhook endpoint verifies Gmail notifications
|
|
||||||
- Only processes emails for authenticated users
|
|
||||||
- Validates store ownership before processing
|
|
||||||
- Logs all actions for audit trail
|
|
||||||
|
|
||||||
## Limitations
|
|
||||||
|
|
||||||
- Gmail watch expires after 7 days (needs renewal)
|
|
||||||
- Only processes unread emails in inbox
|
|
||||||
- Requires active knowledge base for quality replies
|
|
||||||
- May have rate limits from Gmail API
|
|
||||||
|
|
||||||
## Next Steps
|
|
||||||
|
|
||||||
- Set up automatic watch renewal (cron job)
|
|
||||||
- Add email filtering rules (only reply to certain senders)
|
|
||||||
- Implement reply templates
|
|
||||||
- Add analytics dashboard for email metrics
|
|
||||||
@ -1,154 +0,0 @@
|
|||||||
# How to Remove "Google hasn't verified this app" Warning
|
|
||||||
|
|
||||||
## Quick Answer
|
|
||||||
|
|
||||||
The warning appears because your app requests **Gmail scopes** which are sensitive/restricted and require Google verification. You have 3 options:
|
|
||||||
|
|
||||||
## Option 1: Keep in Testing Mode (Easiest - For Development)
|
|
||||||
|
|
||||||
**Best for**: Development, testing, or apps with fewer than 100 users
|
|
||||||
|
|
||||||
### Steps:
|
|
||||||
1. Go to [Google Cloud Console > OAuth consent screen](https://console.cloud.google.com/apis/credentials/consent)
|
|
||||||
2. Click **"Back to testing"** button (if your app is currently "In production")
|
|
||||||
3. Add all Gmail addresses as **test users**:
|
|
||||||
- Scroll to "Test users" section
|
|
||||||
- Click "+ ADD USERS"
|
|
||||||
- Add each Gmail address (one per line)
|
|
||||||
- Click "Add"
|
|
||||||
4. Save changes
|
|
||||||
|
|
||||||
### Result:
|
|
||||||
- ✅ Test users won't see the warning
|
|
||||||
- ✅ Can connect Gmail without verification
|
|
||||||
- ❌ Limited to 100 test users
|
|
||||||
- ❌ Must manually add each user
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Option 2: Get Google Verification (For Production)
|
|
||||||
|
|
||||||
**Best for**: Production apps that need to scale beyond 100 users
|
|
||||||
|
|
||||||
### Prerequisites:
|
|
||||||
Before starting verification, you need:
|
|
||||||
|
|
||||||
1. **Privacy Policy** (Required)
|
|
||||||
- Must be publicly accessible URL
|
|
||||||
- Must explain how you use Gmail data
|
|
||||||
- Example: `https://yourdomain.com/privacy-policy`
|
|
||||||
|
|
||||||
2. **Terms of Service** (Required)
|
|
||||||
- Must be publicly accessible URL
|
|
||||||
- Example: `https://yourdomain.com/terms`
|
|
||||||
|
|
||||||
3. **App Homepage** (Required)
|
|
||||||
- Your app's main website URL
|
|
||||||
- Example: `https://yourdomain.com`
|
|
||||||
|
|
||||||
4. **Authorized Domains**
|
|
||||||
- Add your production domain
|
|
||||||
- Example: `yourdomain.com`
|
|
||||||
|
|
||||||
### Verification Steps:
|
|
||||||
|
|
||||||
1. **Go to Verification Center**:
|
|
||||||
- [OAuth consent screen](https://console.cloud.google.com/apis/credentials/consent)
|
|
||||||
- Click "Verification Center" in left sidebar
|
|
||||||
- Or go directly: [Verification Center](https://console.cloud.google.com/apis/credentials/consent/verification)
|
|
||||||
|
|
||||||
2. **Start Verification**:
|
|
||||||
- Click "Start Verification" or "Submit for verification"
|
|
||||||
- Fill out the verification form
|
|
||||||
|
|
||||||
3. **Complete Verification Form**:
|
|
||||||
- **App name**: Your app name (e.g., "JunoHub")
|
|
||||||
- **Support email**: Your support email address
|
|
||||||
- **App domain**: Your production domain
|
|
||||||
- **Authorized domains**: Add your domain
|
|
||||||
- **Scopes**: Gmail scopes will be automatically listed
|
|
||||||
- **Privacy Policy URL**: Your privacy policy URL
|
|
||||||
- **Terms of Service URL**: Your terms of service URL
|
|
||||||
- **App homepage**: Your app's homepage URL
|
|
||||||
- **App logo**: Optional but recommended
|
|
||||||
|
|
||||||
4. **Additional Information** (may be requested):
|
|
||||||
- **Video demonstration**: Show how your app uses Gmail
|
|
||||||
- **Use case explanation**: Why you need Gmail access
|
|
||||||
- **Data handling**: How you store/process Gmail data
|
|
||||||
|
|
||||||
5. **Submit for Review**:
|
|
||||||
- Review all information
|
|
||||||
- Submit the form
|
|
||||||
- Google will review (typically 4-6 weeks)
|
|
||||||
|
|
||||||
### After Verification:
|
|
||||||
- ✅ Warning disappears for ALL users
|
|
||||||
- ✅ No user limit
|
|
||||||
- ✅ Can request sensitive/restricted scopes
|
|
||||||
- ✅ Professional appearance
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Option 3: Use Domain-Wide Delegation (Advanced - For Google Workspace)
|
|
||||||
|
|
||||||
**Best for**: Google Workspace organizations only
|
|
||||||
|
|
||||||
If you're using Google Workspace, you can use domain-wide delegation instead of OAuth, but this requires admin access and is more complex.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Current Status Check
|
|
||||||
|
|
||||||
Based on your Google Cloud Console screenshot:
|
|
||||||
- ✅ App is "In production"
|
|
||||||
- ✅ User type is "External"
|
|
||||||
- ⚠️ OAuth user cap: 0/100 users
|
|
||||||
- ⚠️ Warning appears because Gmail scopes are unapproved
|
|
||||||
|
|
||||||
## Recommended Action
|
|
||||||
|
|
||||||
**For now (Development/Testing)**:
|
|
||||||
1. Click "Back to testing" in OAuth consent screen
|
|
||||||
2. Add test users (your vendors' Gmail addresses)
|
|
||||||
3. Test users won't see the warning
|
|
||||||
|
|
||||||
**For later (Production)**:
|
|
||||||
1. Create Privacy Policy and Terms of Service pages
|
|
||||||
2. Submit app for Google verification
|
|
||||||
3. Wait for approval (4-6 weeks)
|
|
||||||
4. Warning will disappear automatically
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Privacy Policy Template (Gmail Data Usage)
|
|
||||||
|
|
||||||
Your Privacy Policy must mention Gmail data usage. Example section:
|
|
||||||
|
|
||||||
```markdown
|
|
||||||
## Gmail Integration
|
|
||||||
|
|
||||||
When you connect your Gmail account to [Your App Name], we access:
|
|
||||||
- Read emails: To sync customer emails and create support tickets
|
|
||||||
- Send emails: To send automated responses and notifications
|
|
||||||
- Modify emails: To manage email threads and mark emails as read
|
|
||||||
|
|
||||||
We store:
|
|
||||||
- OAuth tokens (encrypted) for Gmail API access
|
|
||||||
- Email addresses for account identification
|
|
||||||
|
|
||||||
We do NOT:
|
|
||||||
- Store email content permanently
|
|
||||||
- Share your emails with third parties
|
|
||||||
- Access emails outside of the app's functionality
|
|
||||||
|
|
||||||
You can revoke access at any time by disconnecting Gmail in your account settings.
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Need Help?
|
|
||||||
|
|
||||||
- [Google OAuth Verification Guide](https://support.google.com/cloud/answer/9110914)
|
|
||||||
- [OAuth Consent Screen Documentation](https://developers.google.com/identity/protocols/oauth2/policy)
|
|
||||||
- [Gmail API Scopes Documentation](https://developers.google.com/gmail/api/auth/scopes)
|
|
||||||
178
Documents/STANDEE_GUIDELINES.md
Normal file
178
Documents/STANDEE_GUIDELINES.md
Normal file
@ -0,0 +1,178 @@
|
|||||||
|
### Standee Technical Setup (For the Designer)
|
||||||
|
|
||||||
|
This document defines the **exact technical and visual setup** for the JUNO FYP standee so the designer can produce a print-ready PSD that matches both **university requirements** and **project branding**.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Technical Specifications
|
||||||
|
|
||||||
|
- **Dimensions**: 24" width × 60" height (**portrait** orientation)
|
||||||
|
- **Resolution**: **330 DPI**
|
||||||
|
- **File Type**: **PSD** (Adobe Photoshop source file)
|
||||||
|
- **Design Tools**:
|
||||||
|
- Use **Adobe Photoshop** or **Adobe Illustrator**
|
||||||
|
- **Strictly no Canva**
|
||||||
|
- **Color Palette**:
|
||||||
|
- **Deep Navy**: `#0B0D17` (primary background)
|
||||||
|
- **Electric Indigo**: `#5842FF` (primary accent)
|
||||||
|
- **Neon Purple**: `#A230FF` (secondary accent / highlight)
|
||||||
|
|
||||||
|
Use gradients and glassmorphism effects based on these three core colors.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Section 1 – The Academic Header (Mandatory)
|
||||||
|
|
||||||
|
This is the **top band** of the standee and must respect academic branding rules.
|
||||||
|
|
||||||
|
- **Top Left**:
|
||||||
|
- **FAST-NUCES Logo** (clean version, **no Ayah**)
|
||||||
|
- Ensure the logo is high-resolution and not stretched.
|
||||||
|
|
||||||
|
- **Top Center**:
|
||||||
|
- **Project Title (Largest Font)**:
|
||||||
|
- Text: **JUNO**
|
||||||
|
- This should be the **most prominent text** on the header.
|
||||||
|
|
||||||
|
- **Top Right**:
|
||||||
|
- **Supervisor**:
|
||||||
|
- Format: `Supervisor: Dr./Mr./Engr. [Full Name]`
|
||||||
|
- **Team** (directly below supervisor line):
|
||||||
|
- Format:
|
||||||
|
`Team: [Name 1 – Roll No], [Name 2 – Roll No], ...`
|
||||||
|
|
||||||
|
- **Main Headline (Below Header Bar)**:
|
||||||
|
- Place this just under the header strip, centered across the standee:
|
||||||
|
- Text:
|
||||||
|
**"The Pulse of Global Commerce: AI-Powered Shopify Communication"**
|
||||||
|
|
||||||
|
Use a slightly smaller size than the JUNO title, but still bold and easily readable from ~5 feet.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Section 2 – Problem vs. Solution (Chaos → Control Flow)
|
||||||
|
|
||||||
|
This is the **hero visual area** of the standee. Divide it into **two vertical or diagonally slanted panes** to show the transition from chaos to control.
|
||||||
|
|
||||||
|
### Left Side – “The Communication Gap” (The Chaos)
|
||||||
|
|
||||||
|
- **Label**:
|
||||||
|
- **"The Communication Gap"**
|
||||||
|
- **Visual Style**:
|
||||||
|
- Darker background using **Deep Navy** with red/orange accents.
|
||||||
|
- Use **tangled lines** or messy connectors between bubbles to show disorganization.
|
||||||
|
- **Notification Bubbles (examples)**:
|
||||||
|
- “Where is my order?”
|
||||||
|
- “Wrong Address!”
|
||||||
|
- “Supplier hasn’t replied.”
|
||||||
|
- **Supporting Text (Bullets)**:
|
||||||
|
- **Fragmented Channels**
|
||||||
|
- **Slow Response Times**
|
||||||
|
|
||||||
|
Keep text minimal; this side should visually feel **stressful and cluttered**.
|
||||||
|
|
||||||
|
### Right Side – “The JUNO Standard” (The Control)
|
||||||
|
|
||||||
|
- **Label**:
|
||||||
|
- **"The JUNO Standard"**
|
||||||
|
- **Visual Style**:
|
||||||
|
- Clean, bright **indigo gradient** background (Electric Indigo + Neon Purple).
|
||||||
|
- Use **glassmorphism**: translucent panels with blur and subtle borders.
|
||||||
|
- **Dashboard Mockup (Key Highlights)**:
|
||||||
|
- **Unified Inbox**:
|
||||||
|
- Visual: single clean inbox pane.
|
||||||
|
- Caption: *“Orders linked to messages.”*
|
||||||
|
- **Smart Labels**:
|
||||||
|
- Example label: *“Refund Request – Angry”* with an obvious “AI tag / chip”.
|
||||||
|
- Emphasize AI-detected **intent and mood**.
|
||||||
|
- **JUNOCHAT Panel**:
|
||||||
|
- Show structured supplier instructions:
|
||||||
|
- Order ID
|
||||||
|
- Item(s)
|
||||||
|
- Clear requested action (e.g., “Reship”)
|
||||||
|
- Priority / deadline
|
||||||
|
- Caption: *“Structured, unambiguous supplier instructions.”*
|
||||||
|
|
||||||
|
This side should feel **calm, controlled, and futuristic**, clearly opposite to the left side.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Section 3 – Core Value Pillars (“How It Works”)
|
||||||
|
|
||||||
|
Use the **three-column card layout** from your existing UI/theme. Each column is a **card** with a small icon, title, and one-line description.
|
||||||
|
|
||||||
|
| **For Vendors** | **The AI Hub** | **For Suppliers** |
|
||||||
|
|------------------------------|----------------------------|--------------------------------------|
|
||||||
|
| **Automated Fulfillment** | **Intent Intelligence** | **Structured Coordination** |
|
||||||
|
| Real-time sync of Shopify orders to suppliers instantly. | AI detects mood and intent to prevent chargebacks. | No more vague emails; just clear, actionable data. |
|
||||||
|
|
||||||
|
Design notes:
|
||||||
|
- Cards should use **glassmorphic panels** with Electric Indigo / Neon Purple highlights.
|
||||||
|
- Icons should be simple and tech-forward (e.g., truck/order icon, brain/AI icon, network/flow icon).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Section 4 – Tech Stack & Onboarding
|
||||||
|
|
||||||
|
This is a **smaller informational band**, likely below the value pillars.
|
||||||
|
|
||||||
|
### 5.1 Technologies Used
|
||||||
|
|
||||||
|
Use **small, clean icons** beside short labels. Suggested entries:
|
||||||
|
|
||||||
|
- **Shopify API**
|
||||||
|
- **OpenAI / LLMs**
|
||||||
|
- **React / Node.js**
|
||||||
|
- **Webhooks**
|
||||||
|
|
||||||
|
Keep this to one line or a compact grid—avoid long descriptions.
|
||||||
|
|
||||||
|
### 5.2 Onboarding Journey (Horizontal Timeline)
|
||||||
|
|
||||||
|
Show a **3-step horizontal timeline** with minimal text:
|
||||||
|
|
||||||
|
1. **Connect** – Secure Shopify API Sync.
|
||||||
|
2. **Configure** – Set Sync & Fulfillment Preferences.
|
||||||
|
3. **Automate** – AI-driven workflows go live.
|
||||||
|
|
||||||
|
Visually, each step can be a small node with a short label and icon.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Section 5 – Footer & Engagement
|
||||||
|
|
||||||
|
This is the **bottom band** of the standee, driving interaction.
|
||||||
|
|
||||||
|
- **Main Tagline**:
|
||||||
|
- Text:
|
||||||
|
**"Everything you need to dominate the market"**
|
||||||
|
- Centered or slightly left-aligned above the QR and buttons.
|
||||||
|
|
||||||
|
- **QR Code**:
|
||||||
|
- Place a **large, branded QR code** in the **bottom right corner**.
|
||||||
|
- Label under/next to it:
|
||||||
|
- **"Scan for Live Demo/Project Details"**
|
||||||
|
|
||||||
|
- **Role Call-to-Action (Mock Buttons)**:
|
||||||
|
- Use **button-style graphics** (not real buttons) to show the two sides of the platform:
|
||||||
|
- **Sign Up as Vendor**
|
||||||
|
- **Sign Up as Supplier**
|
||||||
|
- Place these near the QR code or centered above it, clearly indicating it is a **two-sided marketplace**.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Important Instructions for the Designer
|
||||||
|
|
||||||
|
- **Readability**:
|
||||||
|
- Ensure the **project title** (`JUNO`) and **core value pillars** are clearly readable from at least **5 feet away**.
|
||||||
|
- Avoid overly thin fonts for key headings.
|
||||||
|
|
||||||
|
- **Visuals Over Text**:
|
||||||
|
- Prioritize **dashboard screen captures**, **AI icons**, and **simple diagrams** over paragraphs.
|
||||||
|
- All descriptions should be **1–2 short lines** only.
|
||||||
|
|
||||||
|
- **Consistency & Branding**:
|
||||||
|
- Use the specified **indigo gradient** and **glassmorphism** (transparency + blur + soft borders) to match app screenshots and maintain brand identity.
|
||||||
|
- Keep spacing, corner radii, and typography style consistent across all cards and panels.
|
||||||
|
|
||||||
|
The goal is a standee that is **instantly scannable, visually premium, and clearly communicates JUNO’s value** to both vendors and suppliers.
|
||||||
@ -1,28 +0,0 @@
|
|||||||
[
|
|
||||||
{
|
|
||||||
"question": "What is your standard order processing time?",
|
|
||||||
"answer": "Orders are typically processed within 1–2 business days. During peak seasons, processing may take up to 3 business days.",
|
|
||||||
"tags": ["orders", "shipping", "processing_time"]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"question": "How long does shipping usually take?",
|
|
||||||
"answer": "Standard shipping within the US takes 3–7 business days after the order has been processed. International shipping can take 7–21 business days depending on the destination and carrier.",
|
|
||||||
"tags": ["shipping", "delivery"]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"question": "What is your return policy?",
|
|
||||||
"answer": "You can return most items within 30 days of delivery in their original condition for a full refund. Items marked as final sale are not eligible for returns.",
|
|
||||||
"tags": ["returns", "refunds", "policy"]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"question": "How do I start a return?",
|
|
||||||
"answer": "To start a return, go to your Orders page, select the order you want to return, and click \"Request Return\". Our team will email you a prepaid return label and instructions within 24 hours.",
|
|
||||||
"tags": ["returns", "how_to"]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"question": "Can I change or cancel my order after placing it?",
|
|
||||||
"answer": "Orders can be changed or canceled within 1 hour of placement. After that, we start processing and cannot guarantee changes. Please contact support as soon as possible with your order number.",
|
|
||||||
"tags": ["orders", "changes", "cancellations"]
|
|
||||||
}
|
|
||||||
]
|
|
||||||
|
|
||||||
136
Documents/suggestiondesign.md
Normal file
136
Documents/suggestiondesign.md
Normal file
@ -0,0 +1,136 @@
|
|||||||
|
### Standee Concept: “Chaos to Control”
|
||||||
|
|
||||||
|
This concept is designed to be **high-impact** and instantly relatable to both **Vendor** and **Supplier** personas, visually contrasting the “old way” of dropshipping operations with **The JUNO Way**.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Top Header
|
||||||
|
|
||||||
|
- **Main Headline**:
|
||||||
|
**SCALE YOUR REACH. SIMPLIFY YOUR SUPPLY.**
|
||||||
|
|
||||||
|
Position this prominently at the top of the standee, above the split-screen section, with strong, bold typography.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Split-Screen Comparison (Problem vs Solution)
|
||||||
|
|
||||||
|
Use a **left/right split** to immediately show “before vs after”.
|
||||||
|
|
||||||
|
### Left Side – The Problem (“The Old Way”)
|
||||||
|
|
||||||
|
- **Visual Style**:
|
||||||
|
- Darker, slightly noisy / cluttered background.
|
||||||
|
- Red or orange accents to convey stress and urgency.
|
||||||
|
- **Notification Bubbles** (examples):
|
||||||
|
- “Where is my order?”
|
||||||
|
- “Wrong Address!”
|
||||||
|
- “Supplier hasn’t replied.”
|
||||||
|
- **Label / Tagline**:
|
||||||
|
- **Fragmented & Slow**
|
||||||
|
- **Overall Feel**:
|
||||||
|
- Chaotic, multiple channels, unclear ownership, lots of manual work.
|
||||||
|
|
||||||
|
### Right Side – The Solution (“The JUNO Way”)
|
||||||
|
|
||||||
|
- **Visual Style**:
|
||||||
|
- Bright, clean, glowing **indigo** background.
|
||||||
|
- Futuristic but minimal UI elements.
|
||||||
|
- **Dashboard Mockup (Key Elements)**:
|
||||||
|
- **Unified Inbox**:
|
||||||
|
- Messages and orders are visually connected.
|
||||||
|
- Example annotation: *“Orders linked to messages automatically.”*
|
||||||
|
- **Smart Labels**:
|
||||||
|
- Tag examples:
|
||||||
|
“Refund Request – Angry – High Priority”
|
||||||
|
- Emphasize AI-driven mood + intent detection.
|
||||||
|
- **JUNOCHAT Panel**:
|
||||||
|
- Show a clear, structured reshipment request:
|
||||||
|
- Order reference
|
||||||
|
- Items
|
||||||
|
- Action: “Reship”
|
||||||
|
- Deadline / priority
|
||||||
|
- Callout text: *“Clear, structured reshipment requests.”*
|
||||||
|
- **Label / Tagline**:
|
||||||
|
- **Connected & Controlled**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Middle Section – Core Value Pillars
|
||||||
|
|
||||||
|
Use a **three-column card layout**, consistent with your existing UI/theme.
|
||||||
|
|
||||||
|
Each card should have:
|
||||||
|
- An icon or small illustration.
|
||||||
|
- A short title.
|
||||||
|
- 1–2 lines of supporting text.
|
||||||
|
|
||||||
|
### Card 1 – For Vendors
|
||||||
|
|
||||||
|
- **Title**: **Automated Fulfillment**
|
||||||
|
- **Subtitle / Description**:
|
||||||
|
- “Sync Shopify orders to suppliers instantly.”
|
||||||
|
- Emphasize reduced manual work and faster response time.
|
||||||
|
|
||||||
|
### Card 2 – The AI Hub
|
||||||
|
|
||||||
|
- **Title**: **Intent Intelligence**
|
||||||
|
- **Subtitle / Description**:
|
||||||
|
- “AI detects mood and promises to prevent chargebacks.”
|
||||||
|
- Highlight:
|
||||||
|
- Intent detection
|
||||||
|
- Sentiment/mood analysis
|
||||||
|
- Policy-aware responses that reduce disputes
|
||||||
|
|
||||||
|
### Card 3 – For Suppliers
|
||||||
|
|
||||||
|
- **Title**: **Structured Coordination**
|
||||||
|
- **Subtitle / Description**:
|
||||||
|
- “No more vague emails; just actionable data.”
|
||||||
|
- Emphasize:
|
||||||
|
- Clear order references
|
||||||
|
- Standardized requests
|
||||||
|
- Less back-and-forth
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Footer Area
|
||||||
|
|
||||||
|
The footer should reinforce the overall promise and invite action.
|
||||||
|
|
||||||
|
### Tagline
|
||||||
|
|
||||||
|
- **Text**:
|
||||||
|
**Everything you need to dominate the market**
|
||||||
|
|
||||||
|
Place this as a bold line above the QR and buttons.
|
||||||
|
|
||||||
|
### QR Code
|
||||||
|
|
||||||
|
- Large, branded **QR code** in one bottom corner (e.g., bottom-right).
|
||||||
|
- Label near the QR, e.g.:
|
||||||
|
- “Scan to book a demo”
|
||||||
|
- or “Scan to explore JUNO”
|
||||||
|
|
||||||
|
### Call-to-Action Buttons (Visual Only)
|
||||||
|
|
||||||
|
Use button-style graphics to show that JUNO is a **two-sided marketplace**:
|
||||||
|
|
||||||
|
- **Button 1**:
|
||||||
|
**Sign Up as Vendor**
|
||||||
|
- **Button 2**:
|
||||||
|
**Sign Up as Supplier**
|
||||||
|
|
||||||
|
These are **mock-up UI buttons** on the standee (not functional), but they visually communicate:
|
||||||
|
- There are dedicated paths for both vendors and suppliers.
|
||||||
|
- JUNO serves the entire chain from **customer → vendor → supplier**.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Overall Design Notes
|
||||||
|
|
||||||
|
- Keep text **short and punchy**; the standee should be scannable in a few seconds.
|
||||||
|
- Use your **brand colors** (especially indigo and accent colors) to tie the standee back to the product UI.
|
||||||
|
- Lean on **icons, highlights, and short captions** rather than long paragraphs.
|
||||||
|
- Ensure the **“Chaos vs Control”** contrast is immediately visible from a distance.
|
||||||
|
|
||||||
351
README.md
351
README.md
@ -1,31 +1,270 @@
|
|||||||
## JUNOHUB
|
## JUNO
|
||||||
|
|
||||||
JUNOHUB is a Next.js application that powers an admin and supplier portal with:
|

|
||||||
|
|
||||||
- Authentication and multi-factor auth
|
**Tech stack:** ⚛️ Next.js · 💙 TypeScript · 🐘 PostgreSQL · ☁️ AWS Bedrock · 📬 Gmail API · 🐳 Docker · 🔐 NextAuth
|
||||||
- Gmail integration for support tickets (Juno Engine)
|
|
||||||
- Shopify integration for orders, products, and inventory
|
|
||||||
- Knowledge base and FAQ management
|
|
||||||
- Admin dashboards for stores, suppliers, and email events
|
|
||||||
|
|
||||||
The frontend and backend are implemented in a single Next.js App Router project.
|
Modern commerce and support infrastructure for Shopify‑based dropshipping businesses. 🚀
|
||||||
|
|
||||||
|
JUNO is a full‑stack Next.js application that powers:
|
||||||
|
|
||||||
|
- A **Vendor Portal** for Shopify merchants
|
||||||
|
- A **Supplier Portal** for product providers
|
||||||
|
- An **Admin view** for operations
|
||||||
|
- An AI‑powered helpdesk and knowledge engine called **JUNOCHAT / Juno Engine**
|
||||||
|
|
||||||
|
Everything runs inside a single Next.js App Router project (frontend + backend + background jobs).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Tech Stack
|
## Table of Contents
|
||||||
|
|
||||||
|
1. [High‑Level Overview](#high-level-overview)
|
||||||
|
2. [Core Features](#core-features)
|
||||||
|
- [Vendor Portal](#vendor-portal)
|
||||||
|
- [Supplier Portal](#supplier-portal)
|
||||||
|
- [Admin / Ops](#admin--ops)
|
||||||
|
- [JUNOCHAT (AI Helpdesk)](#junochat-ai-helpdesk)
|
||||||
|
3. [Architecture & Tech Stack](#architecture--tech-stack)
|
||||||
|
4. [Problems & Solutions](#problems--solutions)
|
||||||
|
5. [Future Enhancements](#future-enhancements)
|
||||||
|
- [JUNOCHAT & AI](#future-enhancements-for-junochat--ai)
|
||||||
|
6. [Local Development](#local-development)
|
||||||
|
7. [Docker & Deployment](#docker--deployment)
|
||||||
|
8. [AWS Bedrock & AI Setup](#aws-bedrock--ai-setup)
|
||||||
|
9. [Prisma & Database](#prisma--database)
|
||||||
|
10. [License](#license)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## High‑Level Overview
|
||||||
|
|
||||||
|
JUNO centralizes communication and operations between **vendors** (Shopify stores) and **suppliers**:
|
||||||
|
|
||||||
|
- Vendors can:
|
||||||
|
- Connect Shopify stores and sync products.
|
||||||
|
- Receive products and price offers from suppliers.
|
||||||
|
- See new products in a clean, auto‑refreshing **New Products** view.
|
||||||
|
- Get notifications when suppliers change prices or withdraw products.
|
||||||
|
|
||||||
|
- Suppliers can:
|
||||||
|
- Manage their product catalog with images and pricing.
|
||||||
|
- Accept product sync requests from vendors.
|
||||||
|
- Provide products and custom pricing per vendor.
|
||||||
|
- Edit synced product price/quantity **per vendor** with inline controls.
|
||||||
|
|
||||||
|
- Both sides are supported by:
|
||||||
|
- **JUNOCHAT** – an AI assistant powered by AWS Bedrock (Titan + Claude) that can answer questions based on the knowledge base and FAQs.
|
||||||
|
- A **Juno Engine** area for Gmail and ticketing workflows.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Core Features
|
||||||
|
|
||||||
|
### Vendor Portal
|
||||||
|
|
||||||
|
- **Shopify connections**
|
||||||
|
- Connect one or more Shopify stores to JUNO.
|
||||||
|
- Webhooks keep orders, inventory, and products in sync.
|
||||||
|
|
||||||
|
- **New Products**
|
||||||
|
- Consolidated list of:
|
||||||
|
- Products vendors requested and had **synced** by suppliers.
|
||||||
|
- Products **provided directly** by suppliers via offers.
|
||||||
|
- Clean row‑based layout (no heavy cards) that:
|
||||||
|
- Auto‑refreshes in the background on an interval and when the tab becomes visible.
|
||||||
|
- Avoids UI flicker by only updating when data actually changes.
|
||||||
|
- Product detail sheet with:
|
||||||
|
- Price
|
||||||
|
- Supplier
|
||||||
|
- Optional quantity/amount
|
||||||
|
- Description and image
|
||||||
|
- Clicking outside the detail closes it (no need to aim at a tiny “X”).
|
||||||
|
|
||||||
|
- **Suppliers Directory**
|
||||||
|
- Discover global suppliers.
|
||||||
|
- Send connection invitations.
|
||||||
|
- Track connection status (PENDING / CONNECTED / REJECTED).
|
||||||
|
|
||||||
|
- **Notifications**
|
||||||
|
- Vendors are notified when:
|
||||||
|
- A supplier accepts a product sync request.
|
||||||
|
- A supplier provides a new product.
|
||||||
|
- A supplier updates the price or quantity of an offered/synced product.
|
||||||
|
|
||||||
|
### Supplier Portal
|
||||||
|
|
||||||
|
- **Vendor Connections**
|
||||||
|
- Accept invitations using secure tokens.
|
||||||
|
- See all connected vendors with status filters.
|
||||||
|
- Accept / reject product sync requests from vendors with a dedicated panel.
|
||||||
|
|
||||||
|
- **Synced Products Per Vendor**
|
||||||
|
- For each vendor, suppliers see a **synced products** block:
|
||||||
|
- Compact, scrollable list for large catalogs.
|
||||||
|
- Shows image, title, SKU, description.
|
||||||
|
- Inline edit controls for:
|
||||||
|
- **Price** (for that vendor)
|
||||||
|
- **Available amount / quantity** (for that vendor)
|
||||||
|
- Updates flow through `ProductSync` so the vendor’s New Products view reflects the new price/amount.
|
||||||
|
|
||||||
|
- **Provided Products (Offers)**
|
||||||
|
- Suppliers can:
|
||||||
|
- Select products from their catalog.
|
||||||
|
- Specify **per‑vendor price** and **total quantity**.
|
||||||
|
- See all offers in a tidy list with image, SKU, and timestamps.
|
||||||
|
- **Edit offer**:
|
||||||
|
- Opens a modal (no page jump) to change price and quantity.
|
||||||
|
- Sends a notification to the vendor about the update.
|
||||||
|
- **Withdraw offer**:
|
||||||
|
- Opens a custom confirmation dialog:
|
||||||
|
- “You are about to withdraw *{productName}* from *{vendorName}*…”
|
||||||
|
- On confirm, removes the offer and the product disappears from the vendor’s New Products list.
|
||||||
|
|
||||||
|
- **Products Catalog**
|
||||||
|
- Manage own products (create/update/delete).
|
||||||
|
- Upload images via:
|
||||||
|
- S3 in hosted environments, or
|
||||||
|
- Local `public/uploads` in pure local dev.
|
||||||
|
- Safeguards:
|
||||||
|
- Synced products from vendors are **editable but not deletable**.
|
||||||
|
- UI labels clarify when a product is “Synced” and where it came from.
|
||||||
|
|
||||||
|
### Admin / Ops
|
||||||
|
|
||||||
|
- Admin page with an overview of:
|
||||||
|
- Stores
|
||||||
|
- Vendors
|
||||||
|
- Suppliers
|
||||||
|
- Juno Engine activity
|
||||||
|
|
||||||
|
- Email‑centric ops:
|
||||||
|
- Gmail polling and webhook endpoints.
|
||||||
|
- Email logs and templates.
|
||||||
|
- MFA setup, notifications, and account hygiene flows.
|
||||||
|
|
||||||
|
### JUNOCHAT (AI Helpdesk)
|
||||||
|
|
||||||
|
JUNOCHAT (a.k.a. Juno Engine chat) is the AI layer that:
|
||||||
|
|
||||||
|
- Uses **AWS Bedrock**:
|
||||||
|
- **Amazon Titan Text Embeddings V2** for vector embeddings.
|
||||||
|
- **Anthropic Claude 3 Haiku** for chat and knowledge‑base answers.
|
||||||
|
- Supports:
|
||||||
|
- FAQ ingestion and retrieval.
|
||||||
|
- Knowledge document upload + chunking.
|
||||||
|
- RAG‑style responses for store‑specific questions (orders, products, policies, etc.).
|
||||||
|
- Integrates with the rest of JUNO:
|
||||||
|
- Can respond based on vendor/supplier context.
|
||||||
|
- Ties into the Juno Engine Gmail / ticketing workflow so AI can assist with replies.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Future Enhancements
|
||||||
|
|
||||||
|
### Future Enhancements for JUNOCHAT & AI
|
||||||
|
|
||||||
|
- **Deeper Shopify awareness**
|
||||||
|
- Let JUNOCHAT answer questions like:
|
||||||
|
- “Which supplier provides the top‑selling items in the last 30 days?”
|
||||||
|
- “What is the margin per vendor on synced products?”
|
||||||
|
|
||||||
|
- **Multi‑channel support**
|
||||||
|
- Extend Juno Engine beyond Gmail:
|
||||||
|
- WhatsApp / SMS
|
||||||
|
- Messenger / Instagram DMs
|
||||||
|
- Shopify Inbox
|
||||||
|
|
||||||
|
- **Active suggestion engine**
|
||||||
|
- Use embeddings + usage data to:
|
||||||
|
- Suggest products suppliers should offer to specific vendors.
|
||||||
|
- Recommend pricing bands per market/region.
|
||||||
|
|
||||||
|
- **Self‑service playbooks**
|
||||||
|
- Let admins define reusable “AI playbooks”, e.g.:
|
||||||
|
- “Handle delayed shipment” flow.
|
||||||
|
- “Out‑of‑stock product” flow.
|
||||||
|
- JUNOCHAT would follow these playbooks when generating replies.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Architecture & Tech Stack
|
||||||
|
|
||||||
- **Framework**: Next.js (App Router, TypeScript)
|
- **Framework**: Next.js (App Router, TypeScript)
|
||||||
- **UI**: React + modern Tailwind-style components
|
- **UI**: React + Tailwind‑style utility classes
|
||||||
- **Auth**: NextAuth
|
- **Auth**: NextAuth
|
||||||
- **Database**: PostgreSQL (Prisma ORM)
|
- **Database**: PostgreSQL via Prisma ORM
|
||||||
- **Background / Integrations**:
|
- **Background / Integrations**:
|
||||||
- Gmail APIs (via OAuth)
|
- Gmail APIs (OAuth)
|
||||||
- Shopify APIs
|
- Shopify APIs (orders, products, inventory)
|
||||||
- AWS Bedrock for embeddings and chat (Titan Text Embeddings V2 + Claude 3 Haiku)
|
- AWS Bedrock (Titan embeddings + Claude 3 Haiku chat)
|
||||||
- **Deployment**: Docker + GitHub Actions → AWS EC2
|
- **Deployment**: Docker + GitHub Actions → AWS EC2
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## Problems & Solutions
|
||||||
|
|
||||||
|
### 1. Dropshipping is glued together with spreadsheets and chats 😵💫
|
||||||
|
|
||||||
|
**Typical Shopify dropshipping today:**
|
||||||
|
|
||||||
|
- Vendors discover suppliers in WhatsApp groups, email threads, or random marketplaces.
|
||||||
|
- Product info, pricing, and availability live in:
|
||||||
|
- Google Sheets
|
||||||
|
- Excel files
|
||||||
|
- Long chat histories and voice notes
|
||||||
|
- When something changes (price increases, product discontinued), it’s easy to miss and very hard to trace.
|
||||||
|
|
||||||
|
**How JUNO helps:**
|
||||||
|
|
||||||
|
- Formal **connections** between Shopify stores and suppliers.
|
||||||
|
- Structured models (`ProductSync`, `SupplierProductOffer`) instead of ad‑hoc spreadsheets.
|
||||||
|
- Every new product or change becomes:
|
||||||
|
- A record in the database.
|
||||||
|
- A visible entry in the **New Products** list.
|
||||||
|
- A notification to the affected party (vendor or supplier).
|
||||||
|
|
||||||
|
👉 Result: less “Did you see my message?” and more “The system already updated this.”
|
||||||
|
|
||||||
|
### 2. No single source of truth for cost, price, and margin per vendor 💸
|
||||||
|
|
||||||
|
**In a typical Shopify setup:**
|
||||||
|
|
||||||
|
- Supplier sends a Google Sheet with “latest prices”.
|
||||||
|
- Vendor copies that into Shopify, maybe with manual mark‑ups.
|
||||||
|
- If the supplier changes cost for **one** vendor, everyone else may still see old prices.
|
||||||
|
- Nobody is sure what the **current agreed cost** is for a given product/vendor pair.
|
||||||
|
|
||||||
|
**How JUNO helps:**
|
||||||
|
|
||||||
|
- Suppliers manage **per‑vendor offers** directly in the Supplier Portal:
|
||||||
|
- Price and quantity are editable inline.
|
||||||
|
- Synced products from vendors can be overridden per store.
|
||||||
|
- Vendors see the resulting price and available amount in one place:
|
||||||
|
- The **New Products** page, refreshed in the background.
|
||||||
|
- Notifications whenever suppliers change price/quantity.
|
||||||
|
|
||||||
|
👉 Result: vendors always see the latest cost per product per supplier, and suppliers stay in control of what each partner gets.
|
||||||
|
|
||||||
|
### 3. Operational noise across email, tickets, and knowledge 💬
|
||||||
|
|
||||||
|
**Pain today:**
|
||||||
|
|
||||||
|
- Support lives in Gmail, while operations live in Shopify, and documentation is buried in Notion/Docs.
|
||||||
|
- Agents manually copy/paste order IDs, tracking links, and policy snippets into replies.
|
||||||
|
- Repeated questions (“Where is my order?”, “What’s your return policy?”) steal time from real issues.
|
||||||
|
|
||||||
|
**How JUNO helps:**
|
||||||
|
|
||||||
|
- **JUNOCHAT + Juno Engine**:
|
||||||
|
- Ingest FAQs and internal docs into a vectorized knowledge base (via Bedrock embeddings).
|
||||||
|
- Answer questions using **RAG** against that knowledge.
|
||||||
|
- Integrate with Gmail so tickets and AI context live in the same place.
|
||||||
|
|
||||||
|
👉 Result: fewer repetitive replies, faster accurate responses, and a single surface for “what we know” + “what customers ask”.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Local Development
|
## Local Development
|
||||||
|
|
||||||
1. **Install dependencies**
|
1. **Install dependencies**
|
||||||
@ -66,7 +305,7 @@ Visit `http://localhost:3000` in your browser.
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Docker
|
## Docker & Deployment
|
||||||
|
|
||||||
The project can be run via Docker using the provided `Dockerfile` and `docker-compose.yml`.
|
The project can be run via Docker using the provided `Dockerfile` and `docker-compose.yml`.
|
||||||
|
|
||||||
@ -78,9 +317,7 @@ docker compose up -d --build
|
|||||||
|
|
||||||
Make sure your `.env` is present next to the app code so the container can read it.
|
Make sure your `.env` is present next to the app code so the container can read it.
|
||||||
|
|
||||||
---
|
### CI/CD on AWS EC2
|
||||||
|
|
||||||
## CI/CD on AWS EC2
|
|
||||||
|
|
||||||
This repo includes a GitHub Actions workflow at `.github/workflows/deploy-ec2.yml` that:
|
This repo includes a GitHub Actions workflow at `.github/workflows/deploy-ec2.yml` that:
|
||||||
|
|
||||||
@ -91,7 +328,7 @@ This repo includes a GitHub Actions workflow at `.github/workflows/deploy-ec2.ym
|
|||||||
5. Uses `docker compose` / `docker-compose` to build and run the app.
|
5. Uses `docker compose` / `docker-compose` to build and run the app.
|
||||||
6. Performs a health check and **automatically rolls back** to the previous commit if the deployment or health check fails.
|
6. Performs a health check and **automatically rolls back** to the previous commit if the deployment or health check fails.
|
||||||
|
|
||||||
### Required GitHub Secrets
|
#### Required GitHub Secrets
|
||||||
|
|
||||||
Set the following secrets in **GitHub → Settings → Secrets and variables → Actions**:
|
Set the following secrets in **GitHub → Settings → Secrets and variables → Actions**:
|
||||||
|
|
||||||
@ -102,7 +339,7 @@ Set the following secrets in **GitHub → Settings → Secrets and variables →
|
|||||||
- `EC2_ENV` – **multi-line secret containing the full contents of the `.env` file** used in production.
|
- `EC2_ENV` – **multi-line secret containing the full contents of the `.env` file** used in production.
|
||||||
This is written to `.env` on the server during deployment.
|
This is written to `.env` on the server during deployment.
|
||||||
|
|
||||||
### Deployment Flow
|
#### Deployment Flow
|
||||||
|
|
||||||
- Push to `main`.
|
- Push to `main`.
|
||||||
- GitHub Actions:
|
- GitHub Actions:
|
||||||
@ -119,29 +356,24 @@ This gives you a sharp deployment with an automatic rollback path.
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## AWS Bedrock Setup
|
## AWS Bedrock & AI Setup
|
||||||
|
|
||||||
The knowledge base feature uses AWS Bedrock for AI-powered embeddings and chat completion.
|
The knowledge base and JUNOCHAT features use AWS Bedrock for AI‑powered embeddings and chat completion.
|
||||||
|
|
||||||
### Prerequisites
|
### Prerequisites
|
||||||
|
|
||||||
1. **AWS Account**: Create an account at https://aws.amazon.com/
|
1. **AWS Account**: Create an account at `https://aws.amazon.com/`
|
||||||
2. **Enable Bedrock Access**:
|
2. **Enable Bedrock Access**:
|
||||||
- Go to https://console.aws.amazon.com/bedrock/
|
- Go to `https://console.aws.amazon.com/bedrock/`
|
||||||
- Navigate to "Model access" in the left sidebar
|
- Navigate to **Model access**
|
||||||
- Request access to:
|
- Request access to:
|
||||||
- **Amazon Titan Text Embeddings V2** (for embeddings)
|
- **Amazon Titan Text Embeddings V2** (for embeddings)
|
||||||
- **Anthropic Claude 3 Haiku** (for chat completion)
|
- **Anthropic Claude 3 Haiku** (for chat completion)
|
||||||
- Wait for approval (usually instant for Titan, may take a few hours for Claude)
|
|
||||||
|
|
||||||
### IAM Setup
|
### IAM Setup
|
||||||
|
|
||||||
Create an IAM user or role with Bedrock permissions:
|
Create an IAM user or role with Bedrock permissions:
|
||||||
|
|
||||||
1. Go to IAM Console → Users (or Roles)
|
|
||||||
2. Create a new user/role or select existing
|
|
||||||
3. Attach the following policy (or create a custom policy):
|
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"Version": "2012-10-17",
|
"Version": "2012-10-17",
|
||||||
@ -158,16 +390,7 @@ Create an IAM user or role with Bedrock permissions:
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
4. **Create Access Keys** (for IAM user):
|
Create access keys for that user and add them to `.env`:
|
||||||
- Go to Security credentials tab
|
|
||||||
- Click "Create access key"
|
|
||||||
- Choose "Application running outside AWS" or "EC2" depending on your deployment
|
|
||||||
- Copy the Access Key ID and Secret Access Key
|
|
||||||
- Add these to your `.env` file
|
|
||||||
|
|
||||||
### Environment Variables
|
|
||||||
|
|
||||||
Add to your `.env` file:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
AWS_ACCESS_KEY_ID="your-access-key-id"
|
AWS_ACCESS_KEY_ID="your-access-key-id"
|
||||||
@ -175,61 +398,39 @@ AWS_SECRET_ACCESS_KEY="your-secret-access-key"
|
|||||||
AWS_REGION="us-east-1" # or your preferred region
|
AWS_REGION="us-east-1" # or your preferred region
|
||||||
```
|
```
|
||||||
|
|
||||||
### Database Migration
|
### Database Migration & Re‑Embedding
|
||||||
|
|
||||||
After setting up AWS Bedrock, you need to migrate existing embeddings:
|
After setting up AWS Bedrock:
|
||||||
|
|
||||||
|
1. Apply Prisma migrations:
|
||||||
|
|
||||||
1. **Run the database migration** to change embedding dimensions from 1536 to 1024:
|
|
||||||
```bash
|
```bash
|
||||||
npx prisma migrate deploy
|
npx prisma migrate deploy
|
||||||
```
|
```
|
||||||
|
|
||||||
2. **Re-embed existing knowledge base data**:
|
2. Re‑embed existing knowledge base data:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm run re-embed-knowledge-base
|
npm run re-embed-knowledge-base
|
||||||
```
|
```
|
||||||
|
|
||||||
This script will:
|
This script:
|
||||||
- Read all existing `KnowledgeChunk` records
|
|
||||||
- Generate new 1024-dimension embeddings using Bedrock Titan V2
|
|
||||||
- Update the database with new embeddings
|
|
||||||
- Process in batches to avoid rate limiting
|
|
||||||
|
|
||||||
### Models Used
|
- Reads all existing `KnowledgeChunk` records.
|
||||||
|
- Generates new embeddings using Titan V2.
|
||||||
|
- Updates the database in batches to avoid rate limiting.
|
||||||
|
|
||||||
- **Embeddings**: Amazon Titan Text Embeddings V2 (`amazon.titan-embed-text-v2:0`)
|
---
|
||||||
- Output dimension: 1024 (configurable: 256, 512, or 1024)
|
|
||||||
- Used for: FAQ embeddings, document chunk embeddings, query embeddings
|
|
||||||
|
|
||||||
- **Chat**: Anthropic Claude 3 Haiku (`anthropic.claude-3-haiku-20240307-v1:0`)
|
## Prisma & Database
|
||||||
- Used for: RAG (Retrieval-Augmented Generation) query responses
|
|
||||||
- Fast and cost-effective for knowledge base Q&A
|
|
||||||
|
|
||||||
### Optional: Override Models
|
To inspect the database locally:
|
||||||
|
|
||||||
You can override the default models via environment variables:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
BEDROCK_EMBEDDING_MODEL="amazon.titan-embed-text-v2:0"
|
|
||||||
BEDROCK_CHAT_MODEL="anthropic.claude-3-haiku-20240307-v1:0"
|
|
||||||
```
|
|
||||||
|
|
||||||
### Prisma:
|
|
||||||
|
|
||||||
If you want to visualize the database:
|
|
||||||
```
|
|
||||||
export DATABASE_URL='<YOUR_DB_URL>'
|
export DATABASE_URL='<YOUR_DB_URL>'
|
||||||
npx prisma studio
|
npx prisma studio
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
||||||
### Troubleshooting
|
|
||||||
|
|
||||||
- **"Access Denied" errors**: Ensure Bedrock model access is enabled in AWS Console
|
|
||||||
- **"Invalid credentials"**: Verify `AWS_ACCESS_KEY_ID` and `AWS_SECRET_ACCESS_KEY` are correct
|
|
||||||
- **"Model not found"**: Check that you've requested access to the models in Bedrock Console
|
|
||||||
- **Re-embedding fails**: Ensure AWS credentials are set and Bedrock access is enabled
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|||||||
@ -32,6 +32,8 @@ services:
|
|||||||
# Override DATABASE_URL to use internal docker network hostname
|
# Override DATABASE_URL to use internal docker network hostname
|
||||||
DATABASE_URL: postgresql://${POSTGRES_USER:-postgres}:${POSTGRES_PASSWORD:-password}@db:5432/${POSTGRES_DB:-dropshipping}?schema=public
|
DATABASE_URL: postgresql://${POSTGRES_USER:-postgres}:${POSTGRES_PASSWORD:-password}@db:5432/${POSTGRES_DB:-dropshipping}?schema=public
|
||||||
NODE_ENV: production
|
NODE_ENV: production
|
||||||
|
volumes:
|
||||||
|
- ./public/uploads/supplier-products:/app/public/uploads/supplier-products
|
||||||
depends_on:
|
depends_on:
|
||||||
db:
|
db:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 27 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 36 KiB |
@ -1,6 +1,6 @@
|
|||||||
#!/usr/bin/env tsx
|
#!/usr/bin/env tsx
|
||||||
/**
|
/**
|
||||||
* Seed script for predefined JUNOHUB email templates.
|
* Seed script for predefined JUNO email templates.
|
||||||
*
|
*
|
||||||
* Usage:
|
* Usage:
|
||||||
* tsx scripts/seed-templates.ts
|
* tsx scripts/seed-templates.ts
|
||||||
|
|||||||
@ -69,7 +69,7 @@ export default function AboutPage() {
|
|||||||
transition={{ delay: 0.2 }}
|
transition={{ delay: 0.2 }}
|
||||||
className="max-w-3xl mx-auto text-xl text-slate-300 font-medium leading-relaxed"
|
className="max-w-3xl mx-auto text-xl text-slate-300 font-medium leading-relaxed"
|
||||||
>
|
>
|
||||||
We started JUNOHUB with a simple belief: the global supply chain is broken, and
|
We started JUNO with a simple belief: the global supply chain is broken, and
|
||||||
technology is the only way to fix it. We're building the infrastructure for a
|
technology is the only way to fix it. We're building the infrastructure for a
|
||||||
more connected, efficient, and transparent future.
|
more connected, efficient, and transparent future.
|
||||||
</motion.p>
|
</motion.p>
|
||||||
@ -93,7 +93,7 @@ export default function AboutPage() {
|
|||||||
lost inventory data, and slow synchronization.
|
lost inventory data, and slow synchronization.
|
||||||
</p>
|
</p>
|
||||||
<p>
|
<p>
|
||||||
JUNOHUB eliminates these bottlenecks by providing a unified, high-octane
|
JUNO eliminates these bottlenecks by providing a unified, high-octane
|
||||||
platform where data flows as fast as the commerce it supports.
|
platform where data flows as fast as the commerce it supports.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -140,7 +140,7 @@ export default async function AdminOverviewPage() {
|
|||||||
</h1>
|
</h1>
|
||||||
<p className="text-sm text-slate-400 mt-1 max-w-xl">
|
<p className="text-sm text-slate-400 mt-1 max-w-xl">
|
||||||
High-level, read-only visibility into how data flows between Shopify
|
High-level, read-only visibility into how data flows between Shopify
|
||||||
stores, vendors and suppliers across JUNOHUB.
|
stores, vendors and suppliers across JUNO.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -73,7 +73,7 @@ export async function POST(req: Request) {
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>Password Reset - JUNOHUB</title>
|
<title>Password Reset - JUNO</title>
|
||||||
</head>
|
</head>
|
||||||
<body style="margin: 0; padding: 0; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; background-color: #0a0e1a;">
|
<body style="margin: 0; padding: 0; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; background-color: #0a0e1a;">
|
||||||
<table role="presentation" style="width: 100%; border-collapse: collapse;">
|
<table role="presentation" style="width: 100%; border-collapse: collapse;">
|
||||||
@ -84,8 +84,8 @@ export async function POST(req: Request) {
|
|||||||
<td style="padding: 40px 40px 30px; background: linear-gradient(135deg, rgba(79, 70, 229, 0.2) 0%, rgba(99, 102, 241, 0.1) 100%); border-bottom: 1px solid rgba(255, 255, 255, 0.1);">
|
<td style="padding: 40px 40px 30px; background: linear-gradient(135deg, rgba(79, 70, 229, 0.2) 0%, rgba(99, 102, 241, 0.1) 100%); border-bottom: 1px solid rgba(255, 255, 255, 0.1);">
|
||||||
<div style="text-align: center;">
|
<div style="text-align: center;">
|
||||||
<div style="display: inline-flex; align-items: center; justify-content: center; gap: 12px; margin-bottom: 16px;">
|
<div style="display: inline-flex; align-items: center; justify-content: center; gap: 12px; margin-bottom: 16px;">
|
||||||
<img src="${getEmailLogoUrl()}" alt="JUNOHUB" width="48" height="48" style="display: block; border-radius: 12px; object-fit: contain;" />
|
<img src="${getEmailLogoUrl()}" alt="JUNO" width="48" height="48" style="display: block; border-radius: 12px; object-fit: contain;" />
|
||||||
<h1 style="margin: 0; font-size: 28px; font-weight: 800; color: #ffffff; letter-spacing: -0.5px;">JUNOHUB</h1>
|
<h1 style="margin: 0; font-size: 28px; font-weight: 800; color: #ffffff; letter-spacing: -0.5px;">JUNO</h1>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
@ -114,7 +114,7 @@ export async function POST(req: Request) {
|
|||||||
|
|
||||||
await sendEmail({
|
await sendEmail({
|
||||||
to: email,
|
to: email,
|
||||||
subject: "Reset Your JUNOHUB Password",
|
subject: "Reset Your JUNO Password",
|
||||||
html: emailHtml
|
html: emailHtml
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@ -72,7 +72,7 @@ export async function POST(req: Request) {
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>Verify Your Email - JUNOHUB</title>
|
<title>Verify Your Email - JUNO</title>
|
||||||
</head>
|
</head>
|
||||||
<body style="margin: 0; padding: 0; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; background-color: #0a0e1a;">
|
<body style="margin: 0; padding: 0; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; background-color: #0a0e1a;">
|
||||||
<table role="presentation" style="width: 100%; border-collapse: collapse;">
|
<table role="presentation" style="width: 100%; border-collapse: collapse;">
|
||||||
@ -83,8 +83,8 @@ export async function POST(req: Request) {
|
|||||||
<td style="padding: 40px 40px 30px; background: linear-gradient(135deg, rgba(79, 70, 229, 0.2) 0%, rgba(99, 102, 241, 0.1) 100%); border-bottom: 1px solid rgba(255, 255, 255, 0.1);">
|
<td style="padding: 40px 40px 30px; background: linear-gradient(135deg, rgba(79, 70, 229, 0.2) 0%, rgba(99, 102, 241, 0.1) 100%); border-bottom: 1px solid rgba(255, 255, 255, 0.1);">
|
||||||
<div style="text-align: center;">
|
<div style="text-align: center;">
|
||||||
<div style="display: inline-flex; align-items: center; justify-content: center; gap: 12px; margin-bottom: 16px;">
|
<div style="display: inline-flex; align-items: center; justify-content: center; gap: 12px; margin-bottom: 16px;">
|
||||||
<img src="${getEmailLogoUrl()}" alt="JUNOHUB" width="48" height="48" style="display: block; border-radius: 12px; object-fit: contain;" />
|
<img src="${getEmailLogoUrl()}" alt="JUNO" width="48" height="48" style="display: block; border-radius: 12px; object-fit: contain;" />
|
||||||
<h1 style="margin: 0; font-size: 28px; font-weight: 800; color: #ffffff; letter-spacing: -0.5px;">JUNOHUB</h1>
|
<h1 style="margin: 0; font-size: 28px; font-weight: 800; color: #ffffff; letter-spacing: -0.5px;">JUNO</h1>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
@ -113,7 +113,7 @@ export async function POST(req: Request) {
|
|||||||
|
|
||||||
await sendEmail({
|
await sendEmail({
|
||||||
to: user.email,
|
to: user.email,
|
||||||
subject: "Verify Your JUNOHUB Email",
|
subject: "Verify Your JUNO Email",
|
||||||
html: emailHtml
|
html: emailHtml
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@ -58,7 +58,7 @@ export async function POST(_req: Request) {
|
|||||||
<p>Thank you for your email. This is an automated reply.</p>
|
<p>Thank you for your email. This is an automated reply.</p>
|
||||||
<p>We have received your message and will get back to you soon.</p>
|
<p>We have received your message and will get back to you soon.</p>
|
||||||
<br/>
|
<br/>
|
||||||
<p>Best regards,<br/>JUNOHUB Support Team</p>
|
<p>Best regards,<br/>JUNO Support Team</p>
|
||||||
</div>
|
</div>
|
||||||
`
|
`
|
||||||
|
|
||||||
|
|||||||
@ -19,7 +19,7 @@ export async function POST(req: Request) {
|
|||||||
// and then save it to your 'Newsletter' table.
|
// and then save it to your 'Newsletter' table.
|
||||||
// For now, we'll proceed to sending the welcome email.
|
// For now, we'll proceed to sending the welcome email.
|
||||||
|
|
||||||
const subject = "Welcome to the JUNOHUB Newsletter! ⚡"
|
const subject = "Welcome to the JUNO Newsletter! ⚡"
|
||||||
const emailHtml = generateNewsletterWelcomeHtml(email)
|
const emailHtml = generateNewsletterWelcomeHtml(email)
|
||||||
|
|
||||||
const result = await sendEmail({
|
const result = await sendEmail({
|
||||||
@ -51,7 +51,7 @@ function generateNewsletterWelcomeHtml(email: string): string {
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>Welcome to JUNOHUB</title>
|
<title>Welcome to JUNO</title>
|
||||||
</head>
|
</head>
|
||||||
<body style="margin: 0; padding: 0; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; background-color: #0a0e1a; color: #ffffff;">
|
<body style="margin: 0; padding: 0; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; background-color: #0a0e1a; color: #ffffff;">
|
||||||
<table role="presentation" style="width: 100%; border-collapse: collapse;">
|
<table role="presentation" style="width: 100%; border-collapse: collapse;">
|
||||||
@ -63,8 +63,8 @@ function generateNewsletterWelcomeHtml(email: string): string {
|
|||||||
<td style="padding: 50px 40px 30px; background: linear-gradient(135deg, #4f46e5 0%, #6366f1 100%);">
|
<td style="padding: 50px 40px 30px; background: linear-gradient(135deg, #4f46e5 0%, #6366f1 100%);">
|
||||||
<div style="text-align: center;">
|
<div style="text-align: center;">
|
||||||
<div style="display: inline-flex; align-items: center; justify-content: center; gap: 12px; margin-bottom: 20px;">
|
<div style="display: inline-flex; align-items: center; justify-content: center; gap: 12px; margin-bottom: 20px;">
|
||||||
<img src="${getEmailLogoUrl()}" alt="JUNOHUB" width="56" height="56" style="display: block; border-radius: 16px; object-fit: contain;" />
|
<img src="${getEmailLogoUrl()}" alt="JUNO" width="56" height="56" style="display: block; border-radius: 16px; object-fit: contain;" />
|
||||||
<h1 style="margin: 0; font-size: 32px; font-weight: 900; color: #ffffff; letter-spacing: -1px; display: inline-block; vertical-align: middle; margin-left: 10px;">JUNOHUB</h1>
|
<h1 style="margin: 0; font-size: 32px; font-weight: 900; color: #ffffff; letter-spacing: -1px; display: inline-block; vertical-align: middle; margin-left: 10px;">JUNO</h1>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
@ -77,7 +77,7 @@ function generateNewsletterWelcomeHtml(email: string): string {
|
|||||||
YOU'RE IN THE LOOP. ⚡
|
YOU'RE IN THE LOOP. ⚡
|
||||||
</h2>
|
</h2>
|
||||||
<p style="margin: 0 0 24px; font-size: 16px; line-height: 1.6; color: #cbd5e1;">
|
<p style="margin: 0 0 24px; font-size: 16px; line-height: 1.6; color: #cbd5e1;">
|
||||||
Welcome to the JUNOHUB inner circle. You've successfully subscribed to our newsletter.
|
Welcome to the JUNO inner circle. You've successfully subscribed to our newsletter.
|
||||||
</p>
|
</p>
|
||||||
<p style="margin: 0 0 24px; font-size: 16px; line-height: 1.6; color: #cbd5e1;">
|
<p style="margin: 0 0 24px; font-size: 16px; line-height: 1.6; color: #cbd5e1;">
|
||||||
Get ready for exclusive insights into global supply chain trends, early access to new features, and strategies to scale your commerce ecosystem.
|
Get ready for exclusive insights into global supply chain trends, early access to new features, and strategies to scale your commerce ecosystem.
|
||||||
@ -118,7 +118,7 @@ function generateNewsletterWelcomeHtml(email: string): string {
|
|||||||
<a href="#" style="color: #4f46e5; text-decoration: none;">Unsubscribe</a> • <a href="https://app.jurassiq-dev.org/privacy-policy" style="color: #4f46e5; text-decoration: none;">Privacy Policy</a>
|
<a href="#" style="color: #4f46e5; text-decoration: none;">Unsubscribe</a> • <a href="https://app.jurassiq-dev.org/privacy-policy" style="color: #4f46e5; text-decoration: none;">Privacy Policy</a>
|
||||||
</p>
|
</p>
|
||||||
<p style="margin: 0; font-size: 11px; color: #475569; text-align: center; font-weight: 700; text-transform: uppercase; letter-spacing: 1px;">
|
<p style="margin: 0; font-size: 11px; color: #475569; text-align: center; font-weight: 700; text-transform: uppercase; letter-spacing: 1px;">
|
||||||
© 2026 JUNOHUB Systems. Powering Modern Commerce.
|
© 2026 JUNO Systems. Powering Modern Commerce.
|
||||||
</p>
|
</p>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|||||||
@ -25,7 +25,7 @@ export async function POST() {
|
|||||||
const secret = generateSecret()
|
const secret = generateSecret()
|
||||||
|
|
||||||
// Create service name and account name for the QR code
|
// Create service name and account name for the QR code
|
||||||
const serviceName = process.env.MFA_ISSUER_NAME || "JUNOHUB"
|
const serviceName = process.env.MFA_ISSUER_NAME || "JUNO"
|
||||||
const accountName = user.email || user.name || "User"
|
const accountName = user.email || user.name || "User"
|
||||||
|
|
||||||
// Generate TOTP URI
|
// Generate TOTP URI
|
||||||
|
|||||||
@ -18,7 +18,7 @@ export async function PUT(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const { productId } = await params
|
const { productId } = await params
|
||||||
const { title, description, price, sku, imageUrl } = await req.json()
|
const { title, description, price, sku, imageUrl, quantity } = await req.json()
|
||||||
|
|
||||||
// Get supplier profile
|
// Get supplier profile
|
||||||
const supplierProfile = await prisma.supplierProfile.findUnique({
|
const supplierProfile = await prisma.supplierProfile.findUnique({
|
||||||
@ -40,6 +40,13 @@ export async function PUT(
|
|||||||
include: {
|
include: {
|
||||||
supplier: {
|
supplier: {
|
||||||
select: { id: true }
|
select: { id: true }
|
||||||
|
},
|
||||||
|
store: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
businessName: true,
|
||||||
|
userId: true
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@ -48,7 +55,7 @@ export async function PUT(
|
|||||||
return NextResponse.json({ message: "Synced product not found" }, { status: 404 })
|
return NextResponse.json({ message: "Synced product not found" }, { status: 404 })
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update the shopifyProductData with new values
|
// Update the shopifyProductData with new values (including optional supplier-specific quantity)
|
||||||
const updatedData = {
|
const updatedData = {
|
||||||
...(productSync.shopifyProductData || {}),
|
...(productSync.shopifyProductData || {}),
|
||||||
title: title || productSync.shopifyProductTitle,
|
title: title || productSync.shopifyProductTitle,
|
||||||
@ -58,11 +65,15 @@ export async function PUT(
|
|||||||
price: idx === 0 && price !== undefined ? price.toString() : v.price,
|
price: idx === 0 && price !== undefined ? price.toString() : v.price,
|
||||||
sku: idx === 0 && sku !== undefined ? sku : v.sku
|
sku: idx === 0 && sku !== undefined ? sku : v.sku
|
||||||
})) || [],
|
})) || [],
|
||||||
image: imageUrl ? { src: imageUrl } : productSync.shopifyProductData?.image
|
image: imageUrl ? { src: imageUrl } : productSync.shopifyProductData?.image,
|
||||||
|
supplier_quantity:
|
||||||
|
quantity !== undefined
|
||||||
|
? quantity
|
||||||
|
: productSync.shopifyProductData?.supplier_quantity
|
||||||
}
|
}
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
await (prisma as any).productSync.update({
|
const updatedSync = await (prisma as any).productSync.update({
|
||||||
where: { id: syncId },
|
where: { id: syncId },
|
||||||
data: {
|
data: {
|
||||||
shopifyProductTitle: title || productSync.shopifyProductTitle,
|
shopifyProductTitle: title || productSync.shopifyProductTitle,
|
||||||
@ -70,6 +81,27 @@ export async function PUT(
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Notify the vendor that the supplier updated this synced product
|
||||||
|
try {
|
||||||
|
if (productSync.store?.userId) {
|
||||||
|
const { createNotification } = await import("@/lib/notifications")
|
||||||
|
await createNotification({
|
||||||
|
userId: productSync.store.userId,
|
||||||
|
type: "PRODUCT_UPDATED",
|
||||||
|
title: "Supplier updated product",
|
||||||
|
message: `${supplierProfile.companyName} updated the price or amount for "${title || productSync.shopifyProductTitle}".`,
|
||||||
|
metadata: {
|
||||||
|
syncId,
|
||||||
|
storeId: productSync.store.id,
|
||||||
|
supplierId: supplierProfile.id
|
||||||
|
},
|
||||||
|
url: "/dashboard/new-products"
|
||||||
|
})
|
||||||
|
}
|
||||||
|
} catch (notifError) {
|
||||||
|
console.error("Error creating notification for synced product update:", notifError)
|
||||||
|
}
|
||||||
|
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
message: "Synced product updated successfully",
|
message: "Synced product updated successfully",
|
||||||
product: {
|
product: {
|
||||||
@ -77,9 +109,10 @@ export async function PUT(
|
|||||||
syncId,
|
syncId,
|
||||||
title: title || productSync.shopifyProductTitle,
|
title: title || productSync.shopifyProductTitle,
|
||||||
description,
|
description,
|
||||||
price: parseFloat(price || "0"),
|
price: price !== undefined ? parseFloat(price) : parseFloat(updatedData.variants?.[0]?.price || "0"),
|
||||||
sku,
|
sku,
|
||||||
imageUrl,
|
imageUrl,
|
||||||
|
availableQuantity: updatedData.supplier_quantity,
|
||||||
isSynced: true
|
isSynced: true
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
@ -56,6 +56,11 @@ export async function GET() {
|
|||||||
// Transform synced products to match product format
|
// Transform synced products to match product format
|
||||||
const syncedProductsFormatted = syncedProducts.map((sync: any) => {
|
const syncedProductsFormatted = syncedProducts.map((sync: any) => {
|
||||||
const productData = sync.shopifyProductData || {}
|
const productData = sync.shopifyProductData || {}
|
||||||
|
const supplierQuantity =
|
||||||
|
typeof productData.supplier_quantity === "number"
|
||||||
|
? productData.supplier_quantity
|
||||||
|
: undefined
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: `synced-${sync.id}`, // Unique ID for synced products
|
id: `synced-${sync.id}`, // Unique ID for synced products
|
||||||
syncId: sync.id,
|
syncId: sync.id,
|
||||||
@ -72,6 +77,7 @@ export async function GET() {
|
|||||||
businessName: sync.store.businessName
|
businessName: sync.store.businessName
|
||||||
},
|
},
|
||||||
isSynced: true, // Flag to indicate this is a synced product from vendor
|
isSynced: true, // Flag to indicate this is a synced product from vendor
|
||||||
|
availableQuantity: supplierQuantity,
|
||||||
// Include full Shopify product data for reference
|
// Include full Shopify product data for reference
|
||||||
shopifyData: productData
|
shopifyData: productData
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
import { NextResponse } from "next/server"
|
import { NextResponse } from "next/server"
|
||||||
import { auth } from "@/auth"
|
import { auth } from "@/auth"
|
||||||
import { prisma } from "@/lib/prisma"
|
import { prisma } from "@/lib/prisma"
|
||||||
|
import { createNotification } from "@/lib/notifications"
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* GET /api/supplier/vendors/[connectionId]/offers
|
* GET /api/supplier/vendors/[connectionId]/offers
|
||||||
@ -129,6 +130,19 @@ export async function POST(
|
|||||||
where: {
|
where: {
|
||||||
id: connectionId,
|
id: connectionId,
|
||||||
supplierId: supplierProfile.id
|
supplierId: supplierProfile.id
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
store: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
businessName: true,
|
||||||
|
user: {
|
||||||
|
select: {
|
||||||
|
id: true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
@ -193,6 +207,27 @@ export async function POST(
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Notify vendor that a new product is available in their New Products page
|
||||||
|
try {
|
||||||
|
if (connection.store?.user?.id) {
|
||||||
|
await createNotification({
|
||||||
|
userId: connection.store.user.id,
|
||||||
|
type: "PRODUCT_UPDATED",
|
||||||
|
title: "New product from supplier",
|
||||||
|
message: `${supplierProfile.companyName} has provided a new product "${product.title}" for your store ${connection.store.businessName}.`,
|
||||||
|
metadata: {
|
||||||
|
offerId: offer.id,
|
||||||
|
productId: product.id,
|
||||||
|
supplierId: supplierProfile.id,
|
||||||
|
storeId: connection.store.id
|
||||||
|
},
|
||||||
|
url: "/dashboard/new-products"
|
||||||
|
})
|
||||||
|
}
|
||||||
|
} catch (notifError) {
|
||||||
|
console.error("Error creating notification for supplier product offer:", notifError)
|
||||||
|
}
|
||||||
|
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
message: "Product provided to vendor successfully",
|
message: "Product provided to vendor successfully",
|
||||||
offer
|
offer
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
import { NextResponse } from "next/server"
|
import { NextResponse } from "next/server"
|
||||||
import { auth } from "@/auth"
|
import { auth } from "@/auth"
|
||||||
import { prisma } from "@/lib/prisma"
|
import { prisma } from "@/lib/prisma"
|
||||||
|
import { createNotification } from "@/lib/notifications"
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* PUT /api/supplier/vendors/offers/[offerId]
|
* PUT /api/supplier/vendors/offers/[offerId]
|
||||||
@ -65,6 +66,20 @@ export async function PUT(
|
|||||||
where: {
|
where: {
|
||||||
id: offerId,
|
id: offerId,
|
||||||
supplierId: supplierProfile.id
|
supplierId: supplierProfile.id
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
store: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
businessName: true,
|
||||||
|
userId: true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
product: {
|
||||||
|
select: {
|
||||||
|
title: true
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
@ -81,6 +96,29 @@ export async function PUT(
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Notify vendor that this offer has been updated
|
||||||
|
try {
|
||||||
|
if (existingOffer.store?.userId) {
|
||||||
|
await createNotification({
|
||||||
|
userId: existingOffer.store.userId,
|
||||||
|
type: "PRODUCT_UPDATED",
|
||||||
|
title: "Supplier updated product",
|
||||||
|
message: `${supplierProfile.companyName} updated the price or quantity for "${existingOffer.product.title}".`,
|
||||||
|
metadata: {
|
||||||
|
offerId,
|
||||||
|
storeId: existingOffer.store.id,
|
||||||
|
supplierId: supplierProfile.id
|
||||||
|
},
|
||||||
|
url: "/dashboard/new-products"
|
||||||
|
})
|
||||||
|
}
|
||||||
|
} catch (notifError) {
|
||||||
|
console.error(
|
||||||
|
"Error creating notification for supplier product offer update:",
|
||||||
|
notifError
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
message: "Offer updated successfully",
|
message: "Offer updated successfully",
|
||||||
offer: updatedOffer
|
offer: updatedOffer
|
||||||
|
|||||||
@ -1,8 +1,61 @@
|
|||||||
import { NextResponse } from "next/server"
|
import { NextResponse } from "next/server"
|
||||||
import { auth } from "@/auth"
|
import { auth } from "@/auth"
|
||||||
import { prisma } from "@/lib/prisma"
|
import { prisma } from "@/lib/prisma"
|
||||||
|
import fs from "fs/promises"
|
||||||
|
import path from "path"
|
||||||
|
import { randomUUID } from "crypto"
|
||||||
import { uploadSupplierProductImage } from "@/lib/s3"
|
import { uploadSupplierProductImage } from "@/lib/s3"
|
||||||
|
|
||||||
|
// Store supplier product images.
|
||||||
|
// In production (S3 configured) we use S3.
|
||||||
|
// In local/dev (no S3) we fall back to writing under public so they can be served directly.
|
||||||
|
const LOCAL_UPLOAD_DIR =
|
||||||
|
process.env.SUPPLIER_PRODUCT_IMAGES_DIR || "public/uploads/supplier-products"
|
||||||
|
|
||||||
|
async function ensureUploadDirForSupplier(supplierId: string) {
|
||||||
|
const baseDir = path.resolve(LOCAL_UPLOAD_DIR)
|
||||||
|
const supplierDir = path.join(baseDir, supplierId)
|
||||||
|
await fs.mkdir(supplierDir, { recursive: true })
|
||||||
|
return supplierDir
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildPublicUrl(absoluteFilePath: string) {
|
||||||
|
const publicRoot = path.resolve("public")
|
||||||
|
const normalized = absoluteFilePath.replace(/\\/g, "/")
|
||||||
|
const normalizedPublicRoot = publicRoot.replace(/\\/g, "/")
|
||||||
|
|
||||||
|
if (normalized.startsWith(normalizedPublicRoot)) {
|
||||||
|
const relative = normalized.slice(normalizedPublicRoot.length)
|
||||||
|
return relative.startsWith("/") ? relative : `/${relative}`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback: if not under public, expose from root path based on configured dir
|
||||||
|
const baseDir = path.resolve(LOCAL_UPLOAD_DIR).replace(/\\/g, "/")
|
||||||
|
const relative = normalized.replace(baseDir, "").replace(/^\/?/, "/")
|
||||||
|
return relative
|
||||||
|
}
|
||||||
|
|
||||||
|
async function uploadLocally(params: {
|
||||||
|
supplierId: string
|
||||||
|
buffer: Buffer
|
||||||
|
fileName: string
|
||||||
|
contentType?: string
|
||||||
|
}) {
|
||||||
|
const extFromName = params.fileName.includes(".")
|
||||||
|
? params.fileName.split(".").pop()
|
||||||
|
: undefined
|
||||||
|
const ext = extFromName || "bin"
|
||||||
|
|
||||||
|
const supplierDir = await ensureUploadDirForSupplier(params.supplierId)
|
||||||
|
const fileNameOnDisk = `${randomUUID()}.${ext}`
|
||||||
|
const storagePath = path.join(supplierDir, fileNameOnDisk)
|
||||||
|
|
||||||
|
await fs.writeFile(storagePath, params.buffer)
|
||||||
|
|
||||||
|
const url = buildPublicUrl(storagePath)
|
||||||
|
return { url }
|
||||||
|
}
|
||||||
|
|
||||||
export async function POST(req: Request) {
|
export async function POST(req: Request) {
|
||||||
try {
|
try {
|
||||||
const session = await auth()
|
const session = await auth()
|
||||||
@ -36,14 +89,33 @@ export async function POST(req: Request) {
|
|||||||
const arrayBuffer = await file.arrayBuffer()
|
const arrayBuffer = await file.arrayBuffer()
|
||||||
const buffer = Buffer.from(arrayBuffer)
|
const buffer = Buffer.from(arrayBuffer)
|
||||||
|
|
||||||
|
const originalName = file.name || "image"
|
||||||
|
|
||||||
|
// Try S3 first (for deployed environments). If S3 is not configured, fall back to local.
|
||||||
|
try {
|
||||||
const { url } = await uploadSupplierProductImage({
|
const { url } = await uploadSupplierProductImage({
|
||||||
supplierId: supplierProfile.id,
|
supplierId: supplierProfile.id,
|
||||||
fileBuffer: buffer,
|
fileBuffer: buffer,
|
||||||
contentType: file.type || undefined,
|
contentType: file.type || undefined,
|
||||||
originalName: file.name
|
originalName
|
||||||
})
|
})
|
||||||
|
|
||||||
return NextResponse.json({ url })
|
return NextResponse.json({ url })
|
||||||
|
} catch (s3Error) {
|
||||||
|
console.warn(
|
||||||
|
"[SUPPLIER_PRODUCT_IMAGE_UPLOAD] S3 upload failed or not configured. Falling back to local storage.",
|
||||||
|
s3Error
|
||||||
|
)
|
||||||
|
|
||||||
|
const { url } = await uploadLocally({
|
||||||
|
supplierId: supplierProfile.id,
|
||||||
|
buffer,
|
||||||
|
fileName: originalName,
|
||||||
|
contentType: file.type || undefined
|
||||||
|
})
|
||||||
|
|
||||||
|
return NextResponse.json({ url })
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("[SUPPLIER_PRODUCT_IMAGE_UPLOAD]", error)
|
console.error("[SUPPLIER_PRODUCT_IMAGE_UPLOAD]", error)
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
|
|||||||
@ -53,6 +53,10 @@ export async function GET(
|
|||||||
|
|
||||||
if (sync) {
|
if (sync) {
|
||||||
const productData = sync.shopifyProductData || {}
|
const productData = sync.shopifyProductData || {}
|
||||||
|
const supplierQuantity =
|
||||||
|
typeof productData.supplier_quantity === "number"
|
||||||
|
? productData.supplier_quantity
|
||||||
|
: undefined
|
||||||
const product = {
|
const product = {
|
||||||
id: sync.id,
|
id: sync.id,
|
||||||
syncId: sync.id,
|
syncId: sync.id,
|
||||||
@ -69,6 +73,7 @@ export async function GET(
|
|||||||
syncedAt: sync.acceptedAt || sync.createdAt,
|
syncedAt: sync.acceptedAt || sync.createdAt,
|
||||||
isSynced: true,
|
isSynced: true,
|
||||||
source: "synced",
|
source: "synced",
|
||||||
|
offerQuantity: supplierQuantity,
|
||||||
shopifyData: productData
|
shopifyData: productData
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
5
src/app/api/vendors/synced-products/route.ts
vendored
5
src/app/api/vendors/synced-products/route.ts
vendored
@ -52,6 +52,10 @@ export async function GET() {
|
|||||||
// Transform syncs into product-like objects for display
|
// Transform syncs into product-like objects for display
|
||||||
const syncedProducts = acceptedSyncs.map((sync: any) => {
|
const syncedProducts = acceptedSyncs.map((sync: any) => {
|
||||||
const productData = sync.shopifyProductData || {}
|
const productData = sync.shopifyProductData || {}
|
||||||
|
const supplierQuantity =
|
||||||
|
typeof productData.supplier_quantity === "number"
|
||||||
|
? productData.supplier_quantity
|
||||||
|
: undefined
|
||||||
return {
|
return {
|
||||||
id: sync.id,
|
id: sync.id,
|
||||||
syncId: sync.id,
|
syncId: sync.id,
|
||||||
@ -68,6 +72,7 @@ export async function GET() {
|
|||||||
syncedAt: sync.acceptedAt || sync.createdAt,
|
syncedAt: sync.acceptedAt || sync.createdAt,
|
||||||
isSynced: true, // Flag to indicate this is a synced product
|
isSynced: true, // Flag to indicate this is a synced product
|
||||||
source: "synced",
|
source: "synced",
|
||||||
|
offerQuantity: supplierQuantity,
|
||||||
// Include full Shopify product data for reference
|
// Include full Shopify product data for reference
|
||||||
shopifyData: productData
|
shopifyData: productData
|
||||||
}
|
}
|
||||||
|
|||||||
@ -75,7 +75,7 @@ export async function POST(req: NextRequest) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 🛡️ SECURITY: Only allow VENDORS to use automated replies
|
// 🛡️ SECURITY: Only allow VENDORS to use automated replies
|
||||||
// Suppliers should not send automated responses via JUNOHUB
|
// Suppliers should not send automated responses via JUNO
|
||||||
if (integration.user.role !== "VENDOR") {
|
if (integration.user.role !== "VENDOR") {
|
||||||
logger.info("Skipping Gmail processing for non-vendor role", {
|
logger.info("Skipping Gmail processing for non-vendor role", {
|
||||||
emailAddress,
|
emailAddress,
|
||||||
|
|||||||
@ -33,7 +33,7 @@ export default function CookiesPage() {
|
|||||||
<section>
|
<section>
|
||||||
<h2 className="text-2xl font-black text-white uppercase tracking-tight mb-4">2. How we use cookies</h2>
|
<h2 className="text-2xl font-black text-white uppercase tracking-tight mb-4">2. How we use cookies</h2>
|
||||||
<p>
|
<p>
|
||||||
JUNOHUB uses cookies to:
|
JUNO uses cookies to:
|
||||||
</p>
|
</p>
|
||||||
<ul className="list-disc pl-6 space-y-2">
|
<ul className="list-disc pl-6 space-y-2">
|
||||||
<li>Keep you signed in during your session.</li>
|
<li>Keep you signed in during your session.</li>
|
||||||
|
|||||||
@ -86,7 +86,7 @@ export default function HelpSupportPage() {
|
|||||||
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4 flex-shrink-0">
|
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4 flex-shrink-0">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl md:text-3xl font-extrabold text-white tracking-tight">Help & Support</h1>
|
<h1 className="text-2xl md:text-3xl font-extrabold text-white tracking-tight">Help & Support</h1>
|
||||||
<p className="text-sm md:text-base text-slate-400 mt-1">Find answers and get help with JUNOHUB.</p>
|
<p className="text-sm md:text-base text-slate-400 mt-1">Find answers and get help with JUNO.</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@ -222,7 +222,7 @@ function GmailConnectContent() {
|
|||||||
</button>
|
</button>
|
||||||
|
|
||||||
<p className="text-xs text-slate-500 text-center">
|
<p className="text-xs text-slate-500 text-center">
|
||||||
By connecting, you authorize JUNOHUB to access your Gmail account for email management purposes.
|
By connecting, you authorize JUNO to access your Gmail account for email management purposes.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@ -203,7 +203,7 @@ export default function TicketDetailPage() {
|
|||||||
? (isLight ? "text-indigo-600" : "text-indigo-400")
|
? (isLight ? "text-indigo-600" : "text-indigo-400")
|
||||||
: "text-indigo-100"
|
: "text-indigo-100"
|
||||||
)}>
|
)}>
|
||||||
{isIncoming ? "Customer" : "JUNOHUB"}
|
{isIncoming ? "Customer" : "JUNO"}
|
||||||
</span>
|
</span>
|
||||||
<span
|
<span
|
||||||
className={cn(
|
className={cn(
|
||||||
|
|||||||
@ -91,7 +91,7 @@ export default function TicketsPage() {
|
|||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl md:text-3xl font-extrabold text-white tracking-tight">Tickets</h1>
|
<h1 className="text-2xl md:text-3xl font-extrabold text-white tracking-tight">Tickets</h1>
|
||||||
<p className="text-sm md:text-base text-slate-400 mt-1">
|
<p className="text-sm md:text-base text-slate-400 mt-1">
|
||||||
View conversations between your customers and JUNOHUB support.
|
View conversations between your customers and JUNO support.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
|
|||||||
@ -13,6 +13,8 @@ import {
|
|||||||
Info
|
Info
|
||||||
} from "lucide-react"
|
} from "lucide-react"
|
||||||
import Image from "next/image"
|
import Image from "next/image"
|
||||||
|
import { useTheme } from "@/components/ThemeProvider"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
interface SyncedProduct {
|
interface SyncedProduct {
|
||||||
id: string
|
id: string
|
||||||
@ -42,25 +44,76 @@ export default function NewProductsPage() {
|
|||||||
const [selectedProduct, setSelectedProduct] = useState<SyncedProduct | null>(null)
|
const [selectedProduct, setSelectedProduct] = useState<SyncedProduct | null>(null)
|
||||||
const [detailLoading, setDetailLoading] = useState(false)
|
const [detailLoading, setDetailLoading] = useState(false)
|
||||||
const [detailError, setDetailError] = useState("")
|
const [detailError, setDetailError] = useState("")
|
||||||
|
const { theme } = useTheme()
|
||||||
|
const isLight = theme === "light"
|
||||||
|
const [refreshing, setRefreshing] = useState(false)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchSyncedProducts()
|
// Initial load with full-page skeleton
|
||||||
|
fetchSyncedProducts(false)
|
||||||
|
|
||||||
|
// Periodically refresh in the background without resetting layout
|
||||||
|
const interval = setInterval(() => {
|
||||||
|
fetchSyncedProducts(true)
|
||||||
|
}, 10000) // every 10 seconds
|
||||||
|
|
||||||
|
// Also refresh when the tab becomes visible again, silently
|
||||||
|
const handleVisibilityChange = () => {
|
||||||
|
if (!document.hidden) {
|
||||||
|
fetchSyncedProducts(true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener("visibilitychange", handleVisibilityChange)
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
clearInterval(interval)
|
||||||
|
document.removeEventListener("visibilitychange", handleVisibilityChange)
|
||||||
|
}
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const fetchSyncedProducts = async () => {
|
const fetchSyncedProducts = async (silent: boolean) => {
|
||||||
try {
|
try {
|
||||||
|
if (silent) {
|
||||||
|
setRefreshing(true)
|
||||||
|
} else {
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
|
}
|
||||||
const res = await fetch("/api/vendors/synced-products")
|
const res = await fetch("/api/vendors/synced-products")
|
||||||
if (!res.ok) throw new Error("Failed to fetch synced products")
|
if (!res.ok) throw new Error("Failed to fetch synced products")
|
||||||
const data = await res.json()
|
const data = await res.json()
|
||||||
setProducts(data.products || [])
|
const nextProducts: SyncedProduct[] = data.products || []
|
||||||
|
|
||||||
|
// Avoid unnecessary re-renders (which can cause subtle flicker)
|
||||||
|
setProducts((prev) => {
|
||||||
|
if (prev.length === nextProducts.length) {
|
||||||
|
const same = prev.every((p, idx) => {
|
||||||
|
const n = nextProducts[idx]
|
||||||
|
return (
|
||||||
|
p.id === n.id &&
|
||||||
|
p.price === n.price &&
|
||||||
|
p.sku === n.sku &&
|
||||||
|
p.imageUrl === n.imageUrl &&
|
||||||
|
p.syncedAt === n.syncedAt &&
|
||||||
|
p.source === n.source &&
|
||||||
|
p.offerQuantity === n.offerQuantity
|
||||||
|
)
|
||||||
|
})
|
||||||
|
if (same) return prev
|
||||||
|
}
|
||||||
|
return nextProducts
|
||||||
|
})
|
||||||
setError("")
|
setError("")
|
||||||
} catch {
|
} catch {
|
||||||
setError("Failed to load synced products")
|
setError("Failed to load synced products")
|
||||||
} finally {
|
} finally {
|
||||||
|
if (silent) {
|
||||||
|
setRefreshing(false)
|
||||||
|
} else {
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const openProductDetail = async (product: SyncedProduct) => {
|
const openProductDetail = async (product: SyncedProduct) => {
|
||||||
setSelectedProduct(null)
|
setSelectedProduct(null)
|
||||||
@ -116,70 +169,61 @@ export default function NewProductsPage() {
|
|||||||
<p className="text-sm text-slate-500">Sync products with suppliers from the Inventory page</p>
|
<p className="text-sm text-slate-500">Sync products with suppliers from the Inventory page</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4 md:gap-6">
|
<div className="bg-white/5 backdrop-blur-xl rounded-xl border border-white/10 overflow-hidden">
|
||||||
|
<div className="divide-y divide-white/10">
|
||||||
{products.map((product) => (
|
{products.map((product) => (
|
||||||
<button
|
<button
|
||||||
key={product.id}
|
key={product.id}
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => openProductDetail(product)}
|
onClick={() => openProductDetail(product)}
|
||||||
className="bg-white/5 backdrop-blur-xl rounded-xl md:rounded-2xl border border-white/10 overflow-hidden hover:border-indigo-500/50 transition-colors group text-left"
|
className="w-full flex items-center gap-4 px-4 md:px-6 py-3 md:py-4 hover:bg-white/5 text-left transition-colors"
|
||||||
>
|
>
|
||||||
<div className="relative w-full h-48 bg-white/10 overflow-hidden">
|
<div className="relative w-16 h-16 rounded-lg overflow-hidden bg-white/10 flex-shrink-0">
|
||||||
{product.imageUrl ? (
|
{product.imageUrl ? (
|
||||||
<Image
|
<Image
|
||||||
src={product.imageUrl}
|
src={product.imageUrl}
|
||||||
alt={product.title}
|
alt={product.title}
|
||||||
fill
|
fill
|
||||||
className="object-cover group-hover:scale-105 transition-transform"
|
className="object-cover"
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<div className="w-full h-full flex items-center justify-center">
|
<div className="w-full h-full flex items-center justify-center">
|
||||||
<ImageIcon className="w-12 h-12 text-slate-500" />
|
<ImageIcon className="w-7 h-7 text-slate-500" />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="absolute top-2 right-2 flex gap-2">
|
</div>
|
||||||
<span className="px-2 py-1 bg-emerald-500/20 text-emerald-400 rounded-full text-xs font-bold uppercase">
|
<div className="flex-1 min-w-0 space-y-1">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<h3 className="font-semibold text-white truncate">
|
||||||
|
{product.title}
|
||||||
|
</h3>
|
||||||
|
<span className="px-2 py-0.5 bg-emerald-500/20 text-emerald-400 rounded-full text-[10px] font-bold uppercase whitespace-nowrap">
|
||||||
{product.source === "supplier_offer" ? "From Supplier" : "Synced"}
|
{product.source === "supplier_offer" ? "From Supplier" : "Synced"}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
<div className="p-4 md:p-6">
|
|
||||||
<h3 className="font-bold text-white text-lg mb-2 line-clamp-2 group-hover:text-indigo-400 transition-colors">
|
|
||||||
{product.title}
|
|
||||||
</h3>
|
|
||||||
|
|
||||||
{product.description && (
|
{product.description && (
|
||||||
<p className="text-sm text-slate-400 mb-3 line-clamp-2">
|
<p className="text-xs text-slate-400 line-clamp-2">
|
||||||
{product.description.replace(/<[^>]*>/g, "")}
|
{product.description.replace(/<[^>]*>/g, "")}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
<div className="flex flex-wrap items-center gap-3 text-xs text-slate-500">
|
||||||
<div className="flex items-center justify-between mb-3">
|
<span className="flex items-center gap-1">
|
||||||
<div>
|
<Building2 className="w-3 h-3" />
|
||||||
<p className="text-2xl font-bold text-white">
|
<span className="truncate max-w-[160px]">
|
||||||
${parseFloat(product.price).toFixed(2)}
|
{product.supplier.companyName}
|
||||||
</p>
|
</span>
|
||||||
{product.sku && (
|
</span>
|
||||||
<p className="text-xs text-slate-500 font-mono">SKU: {product.sku}</p>
|
<span className="flex items-center gap-1">
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="pt-3 border-t border-white/10 space-y-2">
|
|
||||||
<div className="flex items-center gap-2 text-sm text-slate-400">
|
|
||||||
<Building2 className="w-4 h-4" />
|
|
||||||
<span className="truncate">{product.supplier.companyName}</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center justify-between gap-2 text-xs text-slate-500">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Calendar className="w-3 h-3" />
|
<Calendar className="w-3 h-3" />
|
||||||
<span>
|
<span>
|
||||||
{product.source === "supplier_offer"
|
{product.source === "supplier_offer"
|
||||||
? `Provided ${new Date(product.syncedAt).toLocaleDateString()}`
|
? `Provided ${new Date(product.syncedAt).toLocaleDateString()}`
|
||||||
: `Synced ${new Date(product.syncedAt).toLocaleDateString()}`}
|
: `Synced ${new Date(product.syncedAt).toLocaleDateString()}`}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</span>
|
||||||
{product.source === "supplier_offer" && product.offerQuantity !== undefined && (
|
{(product.source === "supplier_offer" ||
|
||||||
|
product.source === "synced") &&
|
||||||
|
product.offerQuantity !== undefined && (
|
||||||
<span className="flex items-center gap-1">
|
<span className="flex items-center gap-1">
|
||||||
<Info className="w-3 h-3" />
|
<Info className="w-3 h-3" />
|
||||||
Qty: {product.offerQuantity}
|
Qty: {product.offerQuantity}
|
||||||
@ -187,16 +231,43 @@ export default function NewProductsPage() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="flex flex-col items-end gap-1">
|
||||||
|
<p className="text-xs text-slate-500 uppercase tracking-wide">
|
||||||
|
Price
|
||||||
|
</p>
|
||||||
|
<p className="text-lg font-bold text-white">
|
||||||
|
${parseFloat(product.price).toFixed(2)}
|
||||||
|
</p>
|
||||||
|
{product.sku && (
|
||||||
|
<p className="text-[11px] text-slate-500 font-mono">
|
||||||
|
SKU: {product.sku}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{selectedProduct && (
|
{selectedProduct && (
|
||||||
<div className="fixed inset-0 z-40 flex items-center justify-center bg-black/60 backdrop-blur-sm">
|
<div
|
||||||
<div className="bg-slate-900 border border-white/10 rounded-2xl shadow-xl max-w-2xl w-full mx-4 max-h-[80vh] flex flex-col">
|
className="fixed inset-0 z-40 flex items-center justify-center bg-black/60 backdrop-blur-sm"
|
||||||
|
onClick={() => {
|
||||||
|
setSelectedProduct(null)
|
||||||
|
setDetailError("")
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"rounded-2xl shadow-xl max-w-2xl w-full mx-4 max-h-[80vh] flex flex-col border transition-colors",
|
||||||
|
isLight
|
||||||
|
? "bg-white border-slate-200"
|
||||||
|
: "bg-slate-900 border-white/10"
|
||||||
|
)}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
<div className="flex items-center justify-between px-6 py-4 border-b border-white/10">
|
<div className="flex items-center justify-between px-6 py-4 border-b border-white/10">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Package className="w-5 h-5 text-indigo-400" />
|
<Package className="w-5 h-5 text-indigo-400" />
|
||||||
@ -258,7 +329,8 @@ export default function NewProductsPage() {
|
|||||||
<p className="text-2xl font-bold text-white">
|
<p className="text-2xl font-bold text-white">
|
||||||
${parseFloat(selectedProduct.price).toFixed(2)}
|
${parseFloat(selectedProduct.price).toFixed(2)}
|
||||||
</p>
|
</p>
|
||||||
{selectedProduct.source === "supplier_offer" &&
|
{(selectedProduct.source === "supplier_offer" ||
|
||||||
|
selectedProduct.source === "synced") &&
|
||||||
selectedProduct.offerQuantity !== undefined && (
|
selectedProduct.offerQuantity !== undefined && (
|
||||||
<p className="mt-1 text-xs text-slate-500">
|
<p className="mt-1 text-xs text-slate-500">
|
||||||
Quantity available from supplier:{" "}
|
Quantity available from supplier:{" "}
|
||||||
|
|||||||
@ -16,6 +16,8 @@ import {
|
|||||||
Store
|
Store
|
||||||
} from "lucide-react"
|
} from "lucide-react"
|
||||||
import Image from "next/image"
|
import Image from "next/image"
|
||||||
|
import { useTheme } from "@/components/ThemeProvider"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
interface Supplier {
|
interface Supplier {
|
||||||
id: string
|
id: string
|
||||||
@ -44,6 +46,8 @@ export default function GlobalSuppliersPage() {
|
|||||||
const [showInviteModal, setShowInviteModal] = useState(false)
|
const [showInviteModal, setShowInviteModal] = useState(false)
|
||||||
const [selectedSupplierId, setSelectedSupplierId] = useState<string | null>(null)
|
const [selectedSupplierId, setSelectedSupplierId] = useState<string | null>(null)
|
||||||
const [connectToAllStores, setConnectToAllStores] = useState(false)
|
const [connectToAllStores, setConnectToAllStores] = useState(false)
|
||||||
|
const { theme } = useTheme()
|
||||||
|
const isLight = theme === "light"
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchSuppliers()
|
fetchSuppliers()
|
||||||
@ -277,7 +281,14 @@ export default function GlobalSuppliersPage() {
|
|||||||
{/* Invite Modal */}
|
{/* Invite Modal */}
|
||||||
{showInviteModal && selectedSupplierId && (
|
{showInviteModal && selectedSupplierId && (
|
||||||
<div className="fixed inset-0 bg-black/50 backdrop-blur-sm z-50 flex items-center justify-center p-4">
|
<div className="fixed inset-0 bg-black/50 backdrop-blur-sm z-50 flex items-center justify-center p-4">
|
||||||
<div className="bg-slate-800 rounded-xl border border-white/10 p-6 max-w-md w-full">
|
<div
|
||||||
|
className={cn(
|
||||||
|
"rounded-xl p-6 max-w-md w-full border transition-colors",
|
||||||
|
isLight
|
||||||
|
? "bg-white border-slate-200 shadow-xl"
|
||||||
|
: "bg-slate-800 border-white/10"
|
||||||
|
)}
|
||||||
|
>
|
||||||
<div className="flex items-center justify-between mb-4">
|
<div className="flex items-center justify-between mb-4">
|
||||||
<h3 className="text-lg font-bold text-white">Send Invitation</h3>
|
<h3 className="text-lg font-bold text-white">Send Invitation</h3>
|
||||||
<button
|
<button
|
||||||
@ -297,7 +308,14 @@ export default function GlobalSuppliersPage() {
|
|||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<label className="flex items-start gap-3 p-4 bg-white/5 rounded-lg border border-white/10 cursor-pointer hover:bg-white/10 transition-colors">
|
<label
|
||||||
|
className={cn(
|
||||||
|
"flex items-start gap-3 p-4 rounded-lg border cursor-pointer transition-colors",
|
||||||
|
isLight
|
||||||
|
? "bg-slate-50 border-slate-200 hover:bg-slate-100"
|
||||||
|
: "bg-white/5 border-white/10 hover:bg-white/10"
|
||||||
|
)}
|
||||||
|
>
|
||||||
<input
|
<input
|
||||||
type="radio"
|
type="radio"
|
||||||
name="connectOption"
|
name="connectOption"
|
||||||
@ -311,7 +329,14 @@ export default function GlobalSuppliersPage() {
|
|||||||
</div>
|
</div>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<label className="flex items-start gap-3 p-4 bg-white/5 rounded-lg border border-white/10 cursor-pointer hover:bg-white/10 transition-colors">
|
<label
|
||||||
|
className={cn(
|
||||||
|
"flex items-start gap-3 p-4 rounded-lg border cursor-pointer transition-colors",
|
||||||
|
isLight
|
||||||
|
? "bg-slate-50 border-slate-200 hover:bg-slate-100"
|
||||||
|
: "bg-white/5 border-white/10 hover:bg-white/10"
|
||||||
|
)}
|
||||||
|
>
|
||||||
<input
|
<input
|
||||||
type="radio"
|
type="radio"
|
||||||
name="connectOption"
|
name="connectOption"
|
||||||
|
|||||||
@ -55,7 +55,7 @@ export default function ForgotPasswordPage() {
|
|||||||
<div className="w-12 h-12 rounded-2xl bg-indigo-500/40 border border-white/20 flex items-center justify-center backdrop-blur-sm">
|
<div className="w-12 h-12 rounded-2xl bg-indigo-500/40 border border-white/20 flex items-center justify-center backdrop-blur-sm">
|
||||||
<Zap className="w-6 h-6 text-white fill-white" />
|
<Zap className="w-6 h-6 text-white fill-white" />
|
||||||
</div>
|
</div>
|
||||||
<h1 className="text-2xl font-bold tracking-tight text-white">JUNOHUB</h1>
|
<h1 className="text-2xl font-bold tracking-tight text-white">JUNO</h1>
|
||||||
</div>
|
</div>
|
||||||
<h2 className="text-2xl sm:text-3xl font-extrabold tracking-tight text-white mb-2">
|
<h2 className="text-2xl sm:text-3xl font-extrabold tracking-tight text-white mb-2">
|
||||||
Forgot <span className="text-indigo-400">Password</span>
|
Forgot <span className="text-indigo-400">Password</span>
|
||||||
|
|||||||
@ -15,7 +15,7 @@ const geistMono = Geist_Mono({
|
|||||||
});
|
});
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: "JUNOHUB | Modern Commerce Infrastructure",
|
title: "JUNO | Modern Commerce Infrastructure",
|
||||||
description: "The world's most advanced encrypted ecosystem for vendors and suppliers.",
|
description: "The world's most advanced encrypted ecosystem for vendors and suppliers.",
|
||||||
icons: {
|
icons: {
|
||||||
icon: "/icon.png",
|
icon: "/icon.png",
|
||||||
@ -24,7 +24,7 @@ export const metadata: Metadata = {
|
|||||||
appleWebApp: {
|
appleWebApp: {
|
||||||
capable: true,
|
capable: true,
|
||||||
statusBarStyle: "default",
|
statusBarStyle: "default",
|
||||||
title: "JUNOHUB",
|
title: "JUNO",
|
||||||
},
|
},
|
||||||
formatDetection: {
|
formatDetection: {
|
||||||
telephone: false,
|
telephone: false,
|
||||||
|
|||||||
@ -111,13 +111,13 @@ export default function LoginPage() {
|
|||||||
<div className="w-14 h-14 rounded-2xl bg-indigo-500/10 border border-white/10 flex items-center justify-center backdrop-blur-sm overflow-hidden shadow-lg shadow-indigo-500/20">
|
<div className="w-14 h-14 rounded-2xl bg-indigo-500/10 border border-white/10 flex items-center justify-center backdrop-blur-sm overflow-hidden shadow-lg shadow-indigo-500/20">
|
||||||
<Image
|
<Image
|
||||||
src="/logo.png"
|
src="/logo.png"
|
||||||
alt="JUNOHUB Logo"
|
alt="JUNO Logo"
|
||||||
width={56}
|
width={56}
|
||||||
height={56}
|
height={56}
|
||||||
className="object-cover"
|
className="object-cover"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<h1 className="text-2xl font-bold tracking-tight text-white">JUNOHUB</h1>
|
<h1 className="text-2xl font-bold tracking-tight text-white">JUNO</h1>
|
||||||
</div>
|
</div>
|
||||||
<h2 className="text-2xl sm:text-3xl font-extrabold tracking-tight text-white mb-2">
|
<h2 className="text-2xl sm:text-3xl font-extrabold tracking-tight text-white mb-2">
|
||||||
Welcome <span className="text-indigo-400">Back</span>
|
Welcome <span className="text-indigo-400">Back</span>
|
||||||
@ -263,7 +263,7 @@ export default function LoginPage() {
|
|||||||
|
|
||||||
<div className="mt-8 space-y-2 text-center text-slate-500 text-[10px] uppercase tracking-widest font-bold">
|
<div className="mt-8 space-y-2 text-center text-slate-500 text-[10px] uppercase tracking-widest font-bold">
|
||||||
<div>
|
<div>
|
||||||
JUNOHUB Systems © 2026
|
JUNO Systems © 2026
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center justify-center gap-4 text-[9px] font-semibold normal-case text-slate-400">
|
<div className="flex items-center justify-center gap-4 text-[9px] font-semibold normal-case text-slate-400">
|
||||||
<Link
|
<Link
|
||||||
|
|||||||
@ -2,8 +2,8 @@ import { MetadataRoute } from 'next'
|
|||||||
|
|
||||||
export default function manifest(): MetadataRoute.Manifest {
|
export default function manifest(): MetadataRoute.Manifest {
|
||||||
return {
|
return {
|
||||||
name: 'JUNOHUB',
|
name: 'JUNO',
|
||||||
short_name: 'JUNOHUB',
|
short_name: 'JUNO',
|
||||||
description: 'The Unified Supply Chain Hub',
|
description: 'The Unified Supply Chain Hub',
|
||||||
start_url: '/',
|
start_url: '/',
|
||||||
display: 'standalone',
|
display: 'standalone',
|
||||||
|
|||||||
@ -324,13 +324,13 @@ export default function OnboardingClient() {
|
|||||||
<div className="w-10 h-10 md:w-12 md:h-12 rounded-xl bg-indigo-500/10 border border-white/10 flex items-center justify-center backdrop-blur-sm overflow-hidden shadow-sm">
|
<div className="w-10 h-10 md:w-12 md:h-12 rounded-xl bg-indigo-500/10 border border-white/10 flex items-center justify-center backdrop-blur-sm overflow-hidden shadow-sm">
|
||||||
<Image
|
<Image
|
||||||
src="/logo.png"
|
src="/logo.png"
|
||||||
alt="JUNOHUB Logo"
|
alt="JUNO Logo"
|
||||||
width={48}
|
width={48}
|
||||||
height={48}
|
height={48}
|
||||||
className="object-cover"
|
className="object-cover"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<span className="font-bold text-white tracking-tight text-lg md:text-xl">JUNOHUB</span>
|
<span className="font-bold text-white tracking-tight text-lg md:text-xl">JUNO</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-3 md:gap-6">
|
<div className="flex items-center gap-3 md:gap-6">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
|
|||||||
@ -74,7 +74,7 @@ export default function PlatformPage() {
|
|||||||
transition={{ delay: 0.2 }}
|
transition={{ delay: 0.2 }}
|
||||||
className="max-w-2xl mx-auto text-xl text-slate-400 font-medium"
|
className="max-w-2xl mx-auto text-xl text-slate-400 font-medium"
|
||||||
>
|
>
|
||||||
JUNOHUB isn't just an app—it's a high-performance infrastructure designed to power
|
JUNO isn't just an app—it's a high-performance infrastructure designed to power
|
||||||
the world's most ambitious supply chains.
|
the world's most ambitious supply chains.
|
||||||
</motion.p>
|
</motion.p>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@ -10,7 +10,7 @@ export default function PrivacyPolicyPage() {
|
|||||||
Privacy Policy
|
Privacy Policy
|
||||||
</h1>
|
</h1>
|
||||||
<p className="text-sm text-slate-400">
|
<p className="text-sm text-slate-400">
|
||||||
This page explains how JUNOHUB collects, uses, and protects your
|
This page explains how JUNO collects, uses, and protects your
|
||||||
data, including data accessed from connected services such as
|
data, including data accessed from connected services such as
|
||||||
Gmail.
|
Gmail.
|
||||||
</p>
|
</p>
|
||||||
@ -22,7 +22,7 @@ export default function PrivacyPolicyPage() {
|
|||||||
1. Overview
|
1. Overview
|
||||||
</h2>
|
</h2>
|
||||||
<p>
|
<p>
|
||||||
JUNOHUB is a dropshipping and e-commerce management platform. We
|
JUNO is a dropshipping and e-commerce management platform. We
|
||||||
collect and process only the data necessary to provide and
|
collect and process only the data necessary to provide and
|
||||||
improve our services, operate your account, and integrate with
|
improve our services, operate your account, and integrate with
|
||||||
third-party platforms you choose to connect.
|
third-party platforms you choose to connect.
|
||||||
@ -34,7 +34,7 @@ export default function PrivacyPolicyPage() {
|
|||||||
2. Data We Collect
|
2. Data We Collect
|
||||||
</h2>
|
</h2>
|
||||||
<p className="mb-2">
|
<p className="mb-2">
|
||||||
When you use JUNOHUB, we may collect the following categories of
|
When you use JUNO, we may collect the following categories of
|
||||||
data:
|
data:
|
||||||
</p>
|
</p>
|
||||||
<ul className="list-disc list-inside space-y-1 text-slate-200">
|
<ul className="list-disc list-inside space-y-1 text-slate-200">
|
||||||
@ -43,7 +43,7 @@ export default function PrivacyPolicyPage() {
|
|||||||
Store and order data from your connected e-commerce platforms.
|
Store and order data from your connected e-commerce platforms.
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
Configuration and usage data related to your JUNOHUB account.
|
Configuration and usage data related to your JUNO account.
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</section>
|
</section>
|
||||||
@ -53,9 +53,9 @@ export default function PrivacyPolicyPage() {
|
|||||||
3. Gmail and Google Data
|
3. Gmail and Google Data
|
||||||
</h2>
|
</h2>
|
||||||
<p className="mb-2">
|
<p className="mb-2">
|
||||||
If you connect your Gmail account, JUNOHUB will request access
|
If you connect your Gmail account, JUNO will request access
|
||||||
to Gmail scopes that allow us to read relevant messages in order
|
to Gmail scopes that allow us to read relevant messages in order
|
||||||
to power JUNOHUB's email and support features.
|
to power JUNO's email and support features.
|
||||||
</p>
|
</p>
|
||||||
<ul className="list-disc list-inside space-y-1 text-slate-200 mb-2">
|
<ul className="list-disc list-inside space-y-1 text-slate-200 mb-2">
|
||||||
<li>
|
<li>
|
||||||
@ -67,12 +67,12 @@ export default function PrivacyPolicyPage() {
|
|||||||
advertising or marketing purposes.
|
advertising or marketing purposes.
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
Access to Gmail data is restricted to the JUNOHUB systems that
|
Access to Gmail data is restricted to the JUNO systems that
|
||||||
need it to provide the features you enable.
|
need it to provide the features you enable.
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
<p>
|
<p>
|
||||||
You can revoke JUNOHUB's access to your Google account at
|
You can revoke JUNO's access to your Google account at
|
||||||
any time via your Google Account security settings.
|
any time via your Google Account security settings.
|
||||||
</p>
|
</p>
|
||||||
</section>
|
</section>
|
||||||
@ -83,7 +83,7 @@ export default function PrivacyPolicyPage() {
|
|||||||
</h2>
|
</h2>
|
||||||
<p className="mb-2">We use your data to:</p>
|
<p className="mb-2">We use your data to:</p>
|
||||||
<ul className="list-disc list-inside space-y-1 text-slate-200">
|
<ul className="list-disc list-inside space-y-1 text-slate-200">
|
||||||
<li>Authenticate you and operate your JUNOHUB account.</li>
|
<li>Authenticate you and operate your JUNO account.</li>
|
||||||
<li>
|
<li>
|
||||||
Sync and manage products, orders, and inventory with your
|
Sync and manage products, orders, and inventory with your
|
||||||
connected stores.
|
connected stores.
|
||||||
@ -93,7 +93,7 @@ export default function PrivacyPolicyPage() {
|
|||||||
features.
|
features.
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
Improve the reliability, security, and performance of JUNOHUB.
|
Improve the reliability, security, and performance of JUNO.
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</section>
|
</section>
|
||||||
@ -133,20 +133,20 @@ export default function PrivacyPolicyPage() {
|
|||||||
<p>
|
<p>
|
||||||
If you have questions about this Privacy Policy or our data
|
If you have questions about this Privacy Policy or our data
|
||||||
practices, please contact us using the support email provided in
|
practices, please contact us using the support email provided in
|
||||||
your JUNOHUB account or documentation.
|
your JUNO account or documentation.
|
||||||
</p>
|
</p>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section className="pt-4 border-t border-white/10 mt-6 text-xs text-slate-400">
|
<section className="pt-4 border-t border-white/10 mt-6 text-xs text-slate-400">
|
||||||
<p>
|
<p>
|
||||||
This Privacy Policy is provided for informational purposes and
|
This Privacy Policy is provided for informational purposes and
|
||||||
may be updated from time to time as JUNOHUB evolves.
|
may be updated from time to time as JUNO evolves.
|
||||||
</p>
|
</p>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<footer className="mt-8 flex flex-wrap items-center justify-between gap-2 text-xs text-slate-400">
|
<footer className="mt-8 flex flex-wrap items-center justify-between gap-2 text-xs text-slate-400">
|
||||||
<span>JUNOHUB Systems © {new Date().getFullYear()}</span>
|
<span>JUNO Systems © {new Date().getFullYear()}</span>
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
<Link
|
<Link
|
||||||
href="/terms-of-service"
|
href="/terms-of-service"
|
||||||
|
|||||||
@ -149,7 +149,7 @@ export default function ResetPasswordPage() {
|
|||||||
<div className="w-12 h-12 rounded-2xl bg-indigo-500/40 border border-white/20 flex items-center justify-center backdrop-blur-sm">
|
<div className="w-12 h-12 rounded-2xl bg-indigo-500/40 border border-white/20 flex items-center justify-center backdrop-blur-sm">
|
||||||
<Zap className="w-6 h-6 text-white fill-white" />
|
<Zap className="w-6 h-6 text-white fill-white" />
|
||||||
</div>
|
</div>
|
||||||
<h1 className="text-2xl font-bold tracking-tight text-white">JUNOHUB</h1>
|
<h1 className="text-2xl font-bold tracking-tight text-white">JUNO</h1>
|
||||||
</div>
|
</div>
|
||||||
<h2 className="text-2xl sm:text-3xl font-extrabold tracking-tight text-white mb-2">
|
<h2 className="text-2xl sm:text-3xl font-extrabold tracking-tight text-white mb-2">
|
||||||
Reset <span className="text-indigo-400">Password</span>
|
Reset <span className="text-indigo-400">Password</span>
|
||||||
|
|||||||
@ -57,19 +57,19 @@ function SignupFormContent() {
|
|||||||
<div className="w-14 h-14 rounded-2xl bg-indigo-500/10 border border-white/10 flex items-center justify-center backdrop-blur-sm overflow-hidden shadow-lg shadow-indigo-500/20">
|
<div className="w-14 h-14 rounded-2xl bg-indigo-500/10 border border-white/10 flex items-center justify-center backdrop-blur-sm overflow-hidden shadow-lg shadow-indigo-500/20">
|
||||||
<Image
|
<Image
|
||||||
src="/logo.png"
|
src="/logo.png"
|
||||||
alt="JUNOHUB Logo"
|
alt="JUNO Logo"
|
||||||
width={56}
|
width={56}
|
||||||
height={56}
|
height={56}
|
||||||
className="object-cover"
|
className="object-cover"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<h1 className="text-2xl font-bold tracking-tight text-white">JUNOHUB</h1>
|
<h1 className="text-2xl font-bold tracking-tight text-white">JUNO</h1>
|
||||||
</div>
|
</div>
|
||||||
<h2 className="text-2xl sm:text-3xl font-extrabold tracking-tight text-white mb-2">
|
<h2 className="text-2xl sm:text-3xl font-extrabold tracking-tight text-white mb-2">
|
||||||
Create your <span className="text-indigo-400">Account</span>
|
Create your <span className="text-indigo-400">Account</span>
|
||||||
</h2>
|
</h2>
|
||||||
<p className="text-slate-400 text-sm">
|
<p className="text-slate-400 text-sm">
|
||||||
Join JUNOHUB and streamline your dropshipping business.
|
Join JUNO and streamline your dropshipping business.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@ -3,6 +3,7 @@ import { MobileSupplierSidebar } from "@/components/MobileSupplierSidebar"
|
|||||||
import UserAccountMenu from "@/components/UserAccountMenu"
|
import UserAccountMenu from "@/components/UserAccountMenu"
|
||||||
import NotificationBell from "@/components/NotificationBell"
|
import NotificationBell from "@/components/NotificationBell"
|
||||||
import PageTransition from "@/components/PageTransition"
|
import PageTransition from "@/components/PageTransition"
|
||||||
|
import ThemeToggle from "@/components/ThemeToggle"
|
||||||
import { auth } from "@/auth"
|
import { auth } from "@/auth"
|
||||||
import { redirect } from "next/navigation"
|
import { redirect } from "next/navigation"
|
||||||
|
|
||||||
@ -86,6 +87,7 @@ export default async function SupplierLayout({
|
|||||||
<h2 className="hidden sm:block text-xs md:text-sm font-semibold text-slate-400 uppercase tracking-wider">Supplier Portal</h2>
|
<h2 className="hidden sm:block text-xs md:text-sm font-semibold text-slate-400 uppercase tracking-wider">Supplier Portal</h2>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2 md:gap-4 shrink-0">
|
<div className="flex items-center gap-2 md:gap-4 shrink-0">
|
||||||
|
<ThemeToggle />
|
||||||
<NotificationBell />
|
<NotificationBell />
|
||||||
<div className="hidden sm:flex flex-col text-right">
|
<div className="hidden sm:flex flex-col text-right">
|
||||||
<span className="text-xs font-bold text-white truncate max-w-[120px]">{session.user?.name || "Supplier User"}</span>
|
<span className="text-xs font-bold text-white truncate max-w-[120px]">{session.user?.name || "Supplier User"}</span>
|
||||||
|
|||||||
@ -15,6 +15,8 @@ import {
|
|||||||
Building2
|
Building2
|
||||||
} from "lucide-react"
|
} from "lucide-react"
|
||||||
import Image from "next/image"
|
import Image from "next/image"
|
||||||
|
import { useTheme } from "@/components/ThemeProvider"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
interface Product {
|
interface Product {
|
||||||
id: string
|
id: string
|
||||||
@ -51,6 +53,8 @@ export default function SupplierProductsPage() {
|
|||||||
})
|
})
|
||||||
const [imageFile, setImageFile] = useState<File | null>(null)
|
const [imageFile, setImageFile] = useState<File | null>(null)
|
||||||
const [submitting, setSubmitting] = useState(false)
|
const [submitting, setSubmitting] = useState(false)
|
||||||
|
const { theme } = useTheme()
|
||||||
|
const isLight = theme === "light"
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchProducts()
|
fetchProducts()
|
||||||
@ -273,6 +277,7 @@ export default function SupplierProductsPage() {
|
|||||||
width={40}
|
width={40}
|
||||||
height={40}
|
height={40}
|
||||||
className="rounded-lg object-cover"
|
className="rounded-lg object-cover"
|
||||||
|
unoptimized
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<div className="w-10 h-10 rounded-lg bg-white/5 border border-white/10 flex items-center justify-center">
|
<div className="w-10 h-10 rounded-lg bg-white/5 border border-white/10 flex items-center justify-center">
|
||||||
@ -333,7 +338,14 @@ export default function SupplierProductsPage() {
|
|||||||
{/* Add/Edit Modal */}
|
{/* Add/Edit Modal */}
|
||||||
{showModal && (
|
{showModal && (
|
||||||
<div className="fixed inset-0 bg-black/50 backdrop-blur-sm z-50 flex items-center justify-center p-4">
|
<div className="fixed inset-0 bg-black/50 backdrop-blur-sm z-50 flex items-center justify-center p-4">
|
||||||
<div className="bg-slate-900 rounded-xl border border-white/10 w-full max-w-md p-6">
|
<div
|
||||||
|
className={cn(
|
||||||
|
"rounded-xl w-full max-w-md p-6 border transition-colors",
|
||||||
|
isLight
|
||||||
|
? "bg-white border-slate-200 shadow-xl"
|
||||||
|
: "bg-slate-900 border-white/10"
|
||||||
|
)}
|
||||||
|
>
|
||||||
<div className="flex items-center justify-between mb-6">
|
<div className="flex items-center justify-between mb-6">
|
||||||
<h2 className="text-xl font-bold text-white">
|
<h2 className="text-xl font-bold text-white">
|
||||||
{editingProduct ? "Edit Product" : "Add Product"}
|
{editingProduct ? "Edit Product" : "Add Product"}
|
||||||
|
|||||||
450
src/app/supplier/vendors/[connectionId]/page.tsx
vendored
450
src/app/supplier/vendors/[connectionId]/page.tsx
vendored
@ -15,6 +15,8 @@ import {
|
|||||||
Trash2
|
Trash2
|
||||||
} from "lucide-react"
|
} from "lucide-react"
|
||||||
import Image from "next/image"
|
import Image from "next/image"
|
||||||
|
import { useTheme } from "@/components/ThemeProvider"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
interface StoreSummary {
|
interface StoreSummary {
|
||||||
id: string
|
id: string
|
||||||
@ -63,7 +65,15 @@ interface SupplierProduct {
|
|||||||
price: number
|
price: number
|
||||||
sku: string | null
|
sku: string | null
|
||||||
imageUrl: string | null
|
imageUrl: string | null
|
||||||
|
// Flags and metadata for synced products from vendors
|
||||||
isSynced?: boolean
|
isSynced?: boolean
|
||||||
|
syncId?: string
|
||||||
|
shopifyProductId?: string | null
|
||||||
|
vendor?: {
|
||||||
|
id: string
|
||||||
|
businessName: string
|
||||||
|
} | null
|
||||||
|
availableQuantity?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function SupplierVendorDetailPage() {
|
export default function SupplierVendorDetailPage() {
|
||||||
@ -78,12 +88,25 @@ export default function SupplierVendorDetailPage() {
|
|||||||
|
|
||||||
const [showProvideModal, setShowProvideModal] = useState(false)
|
const [showProvideModal, setShowProvideModal] = useState(false)
|
||||||
const [products, setProducts] = useState<SupplierProduct[]>([])
|
const [products, setProducts] = useState<SupplierProduct[]>([])
|
||||||
|
const [syncedProducts, setSyncedProducts] = useState<SupplierProduct[]>([])
|
||||||
const [productsLoading, setProductsLoading] = useState(false)
|
const [productsLoading, setProductsLoading] = useState(false)
|
||||||
const [selectedProductId, setSelectedProductId] = useState<string>("")
|
const [selectedProductId, setSelectedProductId] = useState<string>("")
|
||||||
const [priceInput, setPriceInput] = useState<string>("")
|
const [priceInput, setPriceInput] = useState<string>("")
|
||||||
const [quantityInput, setQuantityInput] = useState<string>("")
|
const [quantityInput, setQuantityInput] = useState<string>("")
|
||||||
const [submittingOffer, setSubmittingOffer] = useState(false)
|
const [submittingOffer, setSubmittingOffer] = useState(false)
|
||||||
|
|
||||||
|
const [editingSyncedId, setEditingSyncedId] = useState<string>("")
|
||||||
|
const [syncedPriceInput, setSyncedPriceInput] = useState<string>("")
|
||||||
|
const [syncedQuantityInput, setSyncedQuantityInput] = useState<string>("")
|
||||||
|
const [updatingSynced, setUpdatingSynced] = useState(false)
|
||||||
|
const [editingOffer, setEditingOffer] = useState<Offer | null>(null)
|
||||||
|
const [editOfferPrice, setEditOfferPrice] = useState<string>("")
|
||||||
|
const [editOfferQuantity, setEditOfferQuantity] = useState<string>("")
|
||||||
|
const [updatingOffer, setUpdatingOffer] = useState(false)
|
||||||
|
const [withdrawingOffer, setWithdrawingOffer] = useState<Offer | null>(null)
|
||||||
|
const { theme } = useTheme()
|
||||||
|
const isLight = theme === "light"
|
||||||
|
|
||||||
const connectionId = params?.connectionId
|
const connectionId = params?.connectionId
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -110,9 +133,10 @@ export default function SupplierVendorDetailPage() {
|
|||||||
setLoading(true)
|
setLoading(true)
|
||||||
setError("")
|
setError("")
|
||||||
|
|
||||||
const [connRes, offersRes] = await Promise.all([
|
const [connRes, offersRes, productsRes] = await Promise.all([
|
||||||
fetch(`/api/supplier/vendors/${connectionId}`),
|
fetch(`/api/supplier/vendors/${connectionId}`),
|
||||||
fetch(`/api/supplier/vendors/${connectionId}/offers`)
|
fetch(`/api/supplier/vendors/${connectionId}/offers`),
|
||||||
|
fetch("/api/supplier/products")
|
||||||
])
|
])
|
||||||
|
|
||||||
if (!connRes.ok) {
|
if (!connRes.ok) {
|
||||||
@ -129,6 +153,23 @@ export default function SupplierVendorDetailPage() {
|
|||||||
} else {
|
} else {
|
||||||
setOffers([])
|
setOffers([])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Load synced products for this specific vendor (store)
|
||||||
|
if (productsRes.ok) {
|
||||||
|
const productsData = await productsRes.json()
|
||||||
|
const allProducts: SupplierProduct[] = productsData.products || []
|
||||||
|
const storeId = connData.connection?.store?.id
|
||||||
|
if (storeId) {
|
||||||
|
const syncedForVendor = allProducts.filter(
|
||||||
|
(p) => p.isSynced && p.vendor && p.vendor.id === storeId
|
||||||
|
)
|
||||||
|
setSyncedProducts(syncedForVendor)
|
||||||
|
} else {
|
||||||
|
setSyncedProducts([])
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
setSyncedProducts([])
|
||||||
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err instanceof Error ? err.message : "Failed to load vendor")
|
setError(err instanceof Error ? err.message : "Failed to load vendor")
|
||||||
} finally {
|
} finally {
|
||||||
@ -210,11 +251,29 @@ export default function SupplierVendorDetailPage() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleUpdateOffer = async (offerId: string, price: number, quantity: number) => {
|
const openEditOfferModal = (offer: Offer) => {
|
||||||
|
setEditingOffer(offer)
|
||||||
|
setEditOfferPrice(offer.price.toString())
|
||||||
|
setEditOfferQuantity(offer.quantity.toString())
|
||||||
|
setError("")
|
||||||
|
setSuccess("")
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleUpdateOffer = async () => {
|
||||||
|
if (!editingOffer) return
|
||||||
|
const price = parseFloat(editOfferPrice)
|
||||||
|
const quantity = parseInt(editOfferQuantity, 10)
|
||||||
|
|
||||||
|
if (Number.isNaN(price) || price < 0 || Number.isNaN(quantity) || quantity <= 0) {
|
||||||
|
setError("Please enter a valid non-negative price and quantity greater than zero")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setUpdatingOffer(true)
|
||||||
setError("")
|
setError("")
|
||||||
setSuccess("")
|
setSuccess("")
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`/api/supplier/vendors/offers/${offerId}`, {
|
const res = await fetch(`/api/supplier/vendors/offers/${editingOffer.id}`, {
|
||||||
method: "PUT",
|
method: "PUT",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({ price, quantity })
|
body: JSON.stringify({ price, quantity })
|
||||||
@ -224,19 +283,28 @@ export default function SupplierVendorDetailPage() {
|
|||||||
throw new Error(data.message || "Failed to update offer")
|
throw new Error(data.message || "Failed to update offer")
|
||||||
}
|
}
|
||||||
setSuccess(data.message || "Offer updated successfully")
|
setSuccess(data.message || "Offer updated successfully")
|
||||||
|
setEditingOffer(null)
|
||||||
fetchData()
|
fetchData()
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err instanceof Error ? err.message : "Failed to update offer")
|
setError(err instanceof Error ? err.message : "Failed to update offer")
|
||||||
|
} finally {
|
||||||
|
setUpdatingOffer(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleWithdrawOffer = async (offerId: string) => {
|
const handleWithdrawOffer = (offer: Offer) => {
|
||||||
if (!confirm("Are you sure you want to withdraw this product from the vendor?")) return
|
setWithdrawingOffer(offer)
|
||||||
|
setError("")
|
||||||
|
setSuccess("")
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleConfirmWithdraw = async () => {
|
||||||
|
if (!withdrawingOffer) return
|
||||||
setError("")
|
setError("")
|
||||||
setSuccess("")
|
setSuccess("")
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`/api/supplier/vendors/offers/${offerId}`, {
|
const res = await fetch(`/api/supplier/vendors/offers/${withdrawingOffer.id}`, {
|
||||||
method: "DELETE"
|
method: "DELETE"
|
||||||
})
|
})
|
||||||
const data = await res.json().catch(() => ({}))
|
const data = await res.json().catch(() => ({}))
|
||||||
@ -244,12 +312,85 @@ export default function SupplierVendorDetailPage() {
|
|||||||
throw new Error(data.message || "Failed to withdraw offer")
|
throw new Error(data.message || "Failed to withdraw offer")
|
||||||
}
|
}
|
||||||
setSuccess(data.message || "Offer withdrawn successfully")
|
setSuccess(data.message || "Offer withdrawn successfully")
|
||||||
setOffers((prev) => prev.filter((o) => o.id !== offerId))
|
setOffers((prev) => prev.filter((o) => o.id !== withdrawingOffer.id))
|
||||||
|
setWithdrawingOffer(null)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err instanceof Error ? err.message : "Failed to withdraw offer")
|
setError(err instanceof Error ? err.message : "Failed to withdraw offer")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleStartEditSynced = (product: SupplierProduct) => {
|
||||||
|
setEditingSyncedId(product.id)
|
||||||
|
setSyncedPriceInput(product.price.toString())
|
||||||
|
setSyncedQuantityInput(
|
||||||
|
product.availableQuantity !== undefined ? product.availableQuantity.toString() : ""
|
||||||
|
)
|
||||||
|
setError("")
|
||||||
|
setSuccess("")
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleCancelEditSynced = () => {
|
||||||
|
setEditingSyncedId("")
|
||||||
|
setSyncedPriceInput("")
|
||||||
|
setSyncedQuantityInput("")
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSaveSynced = async () => {
|
||||||
|
if (!editingSyncedId) return
|
||||||
|
const product = syncedProducts.find((p) => p.id === editingSyncedId)
|
||||||
|
if (!product) return
|
||||||
|
|
||||||
|
const price = parseFloat(syncedPriceInput)
|
||||||
|
const quantity = parseInt(syncedQuantityInput, 10)
|
||||||
|
|
||||||
|
if (Number.isNaN(price) || price < 0) {
|
||||||
|
setError("Please enter a valid non-negative price")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (Number.isNaN(quantity) || quantity <= 0) {
|
||||||
|
setError("Please enter a valid quantity greater than zero")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setUpdatingSynced(true)
|
||||||
|
setError("")
|
||||||
|
setSuccess("")
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/supplier/products/${editingSyncedId}`, {
|
||||||
|
method: "PUT",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
title: product.title,
|
||||||
|
description: product.description,
|
||||||
|
price,
|
||||||
|
sku: product.sku,
|
||||||
|
imageUrl: product.imageUrl,
|
||||||
|
quantity
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
const data = await res.json().catch(() => ({}))
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error(data.message || "Failed to update synced product")
|
||||||
|
}
|
||||||
|
|
||||||
|
setSyncedProducts((prev) =>
|
||||||
|
prev.map((p) =>
|
||||||
|
p.id === editingSyncedId ? { ...p, price, availableQuantity: quantity } : p
|
||||||
|
)
|
||||||
|
)
|
||||||
|
setSuccess(data.message || "Synced product updated successfully")
|
||||||
|
setEditingSyncedId("")
|
||||||
|
setSyncedPriceInput("")
|
||||||
|
setSyncedQuantityInput("")
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : "Failed to update synced product")
|
||||||
|
} finally {
|
||||||
|
setUpdatingSynced(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const selectedProduct = products.find((p) => p.id === selectedProductId) || null
|
const selectedProduct = products.find((p) => p.id === selectedProductId) || null
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@ -329,6 +470,146 @@ export default function SupplierVendorDetailPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="lg:col-span-2 flex flex-col gap-4">
|
<div className="lg:col-span-2 flex flex-col gap-4">
|
||||||
|
{/* Synced products from this vendor (via product sync requests) */}
|
||||||
|
{syncedProducts.length > 0 && (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex items-center justify-between gap-2">
|
||||||
|
<h2 className="text-lg font-bold text-white flex items-center gap-2">
|
||||||
|
<Package className="w-5 h-5 text-slate-400" />
|
||||||
|
Synced products from this vendor
|
||||||
|
</h2>
|
||||||
|
<span className="px-3 py-1 rounded-full text-xs bg-white/5 border border-white/10 text-slate-300">
|
||||||
|
{syncedProducts.length} product
|
||||||
|
{syncedProducts.length > 1 ? "s" : ""}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="bg-white/5 border border-white/10 rounded-2xl overflow-hidden">
|
||||||
|
<div className="max-h-96 overflow-y-auto divide-y divide-white/5">
|
||||||
|
{syncedProducts.map((product) => {
|
||||||
|
const isEditing = editingSyncedId === product.id
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={product.id}
|
||||||
|
className="flex items-center gap-4 px-4 py-3 hover:bg-white/5 transition-colors"
|
||||||
|
>
|
||||||
|
<div className="relative w-14 h-14 rounded-lg overflow-hidden bg-white/10 flex-shrink-0">
|
||||||
|
{product.imageUrl ? (
|
||||||
|
<Image
|
||||||
|
src={product.imageUrl}
|
||||||
|
alt={product.title}
|
||||||
|
fill
|
||||||
|
className="object-cover"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="w-full h-full flex items-center justify-center text-slate-500">
|
||||||
|
<Package className="w-5 h-5" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-w-0 space-y-1">
|
||||||
|
<p className="text-sm font-semibold text-white truncate">
|
||||||
|
{product.title}
|
||||||
|
</p>
|
||||||
|
<div className="flex flex-wrap items-center gap-2 text-xs text-slate-500">
|
||||||
|
{product.sku && (
|
||||||
|
<span className="font-mono">
|
||||||
|
SKU: {product.sku}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{product.description && (
|
||||||
|
<span className="truncate max-w-[260px]">
|
||||||
|
{product.description}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col items-end gap-1 w-48">
|
||||||
|
<p className="text-[11px] text-slate-500 uppercase tracking-wide">
|
||||||
|
Current price
|
||||||
|
</p>
|
||||||
|
{isEditing ? (
|
||||||
|
<div className="flex items-center gap-1 w-full">
|
||||||
|
<span className="text-slate-400 text-sm">
|
||||||
|
$
|
||||||
|
</span>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
step="0.01"
|
||||||
|
min="0"
|
||||||
|
value={syncedPriceInput}
|
||||||
|
onChange={(e) =>
|
||||||
|
setSyncedPriceInput(e.target.value)
|
||||||
|
}
|
||||||
|
className="flex-1 px-2 py-1 bg-white/5 border border-white/10 rounded-lg text-sm text-white focus:outline-none focus:ring-2 focus:ring-indigo-500/30 focus:border-indigo-500/50"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="text-lg font-bold text-white">
|
||||||
|
${product.price.toFixed(2)}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<p className="text-[11px] text-slate-500 uppercase tracking-wide mt-2">
|
||||||
|
Available amount
|
||||||
|
</p>
|
||||||
|
{isEditing ? (
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min="1"
|
||||||
|
value={syncedQuantityInput}
|
||||||
|
onChange={(e) =>
|
||||||
|
setSyncedQuantityInput(
|
||||||
|
e.target.value
|
||||||
|
)
|
||||||
|
}
|
||||||
|
className="w-full px-2 py-1 bg-white/5 border border-white/10 rounded-lg text-sm text-white focus:outline-none focus:ring-2 focus:ring-indigo-500/30 focus:border-indigo-500/50"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<p className="text-sm font-semibold text-white">
|
||||||
|
{product.availableQuantity ??
|
||||||
|
"—"}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2 mt-2">
|
||||||
|
{isEditing ? (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={updatingSynced}
|
||||||
|
onClick={handleSaveSynced}
|
||||||
|
className="px-2 py-1 rounded-md bg-indigo-500 hover:bg-indigo-600 text-white text-xs font-medium disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
{updatingSynced ? "Saving..." : "Save"}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={updatingSynced}
|
||||||
|
onClick={handleCancelEditSynced}
|
||||||
|
className="px-2 py-1 rounded-md border border-white/10 text-xs text-slate-200 hover:bg-white/5"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleStartEditSynced(product)}
|
||||||
|
className="px-2 py-1 rounded-md bg-white/5 hover:bg-white/10 text-xs text-slate-200 font-medium flex items-center gap-1"
|
||||||
|
>
|
||||||
|
<Edit className="w-3 h-3" />
|
||||||
|
Edit price
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<h2 className="text-lg font-bold text-white flex items-center gap-2">
|
<h2 className="text-lg font-bold text-white flex items-center gap-2">
|
||||||
<Package className="w-5 h-5 text-slate-400" />
|
<Package className="w-5 h-5 text-slate-400" />
|
||||||
@ -409,13 +690,7 @@ export default function SupplierVendorDetailPage() {
|
|||||||
<div className="flex items-center gap-2 md:flex-col md:items-stretch">
|
<div className="flex items-center gap-2 md:flex-col md:items-stretch">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() =>
|
onClick={() => openEditOfferModal(offer)}
|
||||||
handleUpdateOffer(
|
|
||||||
offer.id,
|
|
||||||
offer.price,
|
|
||||||
offer.quantity
|
|
||||||
)
|
|
||||||
}
|
|
||||||
className="flex-1 px-3 py-2 bg-white/10 hover:bg-white/15 text-slate-200 rounded-lg text-sm font-medium flex items-center justify-center gap-2"
|
className="flex-1 px-3 py-2 bg-white/10 hover:bg-white/15 text-slate-200 rounded-lg text-sm font-medium flex items-center justify-center gap-2"
|
||||||
>
|
>
|
||||||
<Edit className="w-4 h-4" />
|
<Edit className="w-4 h-4" />
|
||||||
@ -423,7 +698,7 @@ export default function SupplierVendorDetailPage() {
|
|||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => handleWithdrawOffer(offer.id)}
|
onClick={() => handleWithdrawOffer(offer)}
|
||||||
className="flex-1 px-3 py-2 bg-red-500/20 hover:bg-red-500/30 text-red-400 rounded-lg text-sm font-medium flex items-center justify-center gap-2"
|
className="flex-1 px-3 py-2 bg-red-500/20 hover:bg-red-500/30 text-red-400 rounded-lg text-sm font-medium flex items-center justify-center gap-2"
|
||||||
>
|
>
|
||||||
<Trash2 className="w-4 h-4" />
|
<Trash2 className="w-4 h-4" />
|
||||||
@ -441,7 +716,14 @@ export default function SupplierVendorDetailPage() {
|
|||||||
|
|
||||||
{showProvideModal && (
|
{showProvideModal && (
|
||||||
<div className="fixed inset-0 z-40 flex items-center justify-center bg-black/60 backdrop-blur-sm">
|
<div className="fixed inset-0 z-40 flex items-center justify-center bg-black/60 backdrop-blur-sm">
|
||||||
<div className="bg-slate-900 border border-white/10 rounded-2xl shadow-xl max-w-3xl w-full mx-4 max-h-[80vh] flex flex-col">
|
<div
|
||||||
|
className={cn(
|
||||||
|
"rounded-2xl shadow-xl max-w-3xl w-full mx-4 max-h-[80vh] flex flex-col border transition-colors",
|
||||||
|
isLight
|
||||||
|
? "bg-white border-slate-200"
|
||||||
|
: "bg-slate-900 border-white/10"
|
||||||
|
)}
|
||||||
|
>
|
||||||
<div className="flex items-center justify-between px-6 py-4 border-b border-white/10">
|
<div className="flex items-center justify-between px-6 py-4 border-b border-white/10">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Package className="w-5 h-5 text-indigo-400" />
|
<Package className="w-5 h-5 text-indigo-400" />
|
||||||
@ -623,6 +905,140 @@ export default function SupplierVendorDetailPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{editingOffer && (
|
||||||
|
<div className="fixed inset-0 z-40 flex items-center justify-center bg-black/60 backdrop-blur-sm">
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"rounded-2xl shadow-xl max-w-md w-full mx-4 border transition-colors p-6 space-y-4",
|
||||||
|
isLight ? "bg-white border-slate-200" : "bg-slate-900 border-white/10"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-lg font-bold text-white">Edit offer</h2>
|
||||||
|
<p className="text-xs text-slate-400 mt-1">
|
||||||
|
Update price or quantity for{" "}
|
||||||
|
<span className="font-semibold">{editingOffer.product.title}</span>{" "}
|
||||||
|
for{" "}
|
||||||
|
<span className="font-semibold">
|
||||||
|
{connection?.store.businessName}
|
||||||
|
</span>
|
||||||
|
.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setEditingOffer(null)}
|
||||||
|
className="p-2 rounded-lg hover:bg-white/10 text-slate-400 hover:text-white transition-colors"
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-slate-300 mb-1">
|
||||||
|
Price for this vendor
|
||||||
|
</label>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-slate-400">$</span>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
step="0.01"
|
||||||
|
min="0"
|
||||||
|
value={editOfferPrice}
|
||||||
|
onChange={(e) => setEditOfferPrice(e.target.value)}
|
||||||
|
className="flex-1 px-3 py-2 bg-white/5 border border-white/10 rounded-lg text-white focus:outline-none focus:ring-2 focus:ring-indigo-500/30 focus:border-indigo-500/50"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-slate-300 mb-1">
|
||||||
|
Total items (quantity)
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min="1"
|
||||||
|
value={editOfferQuantity}
|
||||||
|
onChange={(e) => setEditOfferQuantity(e.target.value)}
|
||||||
|
className="w-full px-3 py-2 bg-white/5 border border-white/10 rounded-lg text-white focus:outline-none focus:ring-2 focus:ring-indigo-500/30 focus:border-indigo-500/50"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-end gap-3 pt-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setEditingOffer(null)}
|
||||||
|
className="px-4 py-2 rounded-lg border border-white/10 text-slate-200 hover:bg-white/5 transition-colors text-sm font-medium"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={updatingOffer}
|
||||||
|
onClick={handleUpdateOffer}
|
||||||
|
className="px-4 py-2 rounded-lg bg-indigo-500 hover:bg-indigo-600 disabled:opacity-50 disabled:cursor-not-allowed text-white text-sm font-medium flex items-center gap-2"
|
||||||
|
>
|
||||||
|
{updatingOffer ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="w-4 h-4 animate-spin" />
|
||||||
|
Saving...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
"Save changes"
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{withdrawingOffer && (
|
||||||
|
<div className="fixed inset-0 z-40 flex items-center justify-center bg-black/60 backdrop-blur-sm">
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"rounded-2xl shadow-xl max-w-md w-full mx-4 border transition-colors p-6 space-y-4",
|
||||||
|
isLight ? "bg-white border-slate-200" : "bg-slate-900 border-white/10"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h2 className="text-lg font-bold text-white">Withdraw product</h2>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setWithdrawingOffer(null)}
|
||||||
|
className="p-2 rounded-lg hover:bg-white/10 text-slate-400 hover:text-white transition-colors"
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-slate-300">
|
||||||
|
You are about to withdraw{" "}
|
||||||
|
<span className="font-semibold">
|
||||||
|
{withdrawingOffer.product.title}
|
||||||
|
</span>{" "}
|
||||||
|
from{" "}
|
||||||
|
<span className="font-semibold">
|
||||||
|
{connection?.store.businessName}
|
||||||
|
</span>
|
||||||
|
. This product will no longer appear in their New Products list.
|
||||||
|
</p>
|
||||||
|
<div className="flex items-center justify-end gap-3 pt-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setWithdrawingOffer(null)}
|
||||||
|
className="px-4 py-2 rounded-lg border border-white/10 text-slate-200 hover:bg-white/5 transition-colors text-sm font-medium"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleConfirmWithdraw}
|
||||||
|
className="px-4 py-2 rounded-lg bg-red-500 hover:bg-red-600 text-white text-sm font-medium"
|
||||||
|
>
|
||||||
|
Yes, withdraw
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -10,7 +10,7 @@ export default function TermsOfServicePage() {
|
|||||||
Terms of Service
|
Terms of Service
|
||||||
</h1>
|
</h1>
|
||||||
<p className="text-sm text-slate-400">
|
<p className="text-sm text-slate-400">
|
||||||
These Terms govern your use of JUNOHUB. By creating an account or
|
These Terms govern your use of JUNO. By creating an account or
|
||||||
using the platform, you agree to these Terms.
|
using the platform, you agree to these Terms.
|
||||||
</p>
|
</p>
|
||||||
</header>
|
</header>
|
||||||
@ -18,10 +18,10 @@ export default function TermsOfServicePage() {
|
|||||||
<div className="space-y-6 text-sm md:text-base leading-relaxed text-slate-200">
|
<div className="space-y-6 text-sm md:text-base leading-relaxed text-slate-200">
|
||||||
<section>
|
<section>
|
||||||
<h2 className="text-lg font-semibold text-white mb-2">
|
<h2 className="text-lg font-semibold text-white mb-2">
|
||||||
1. Using JUNOHUB
|
1. Using JUNO
|
||||||
</h2>
|
</h2>
|
||||||
<p>
|
<p>
|
||||||
JUNOHUB provides tools for managing dropshipping and e-commerce
|
JUNO provides tools for managing dropshipping and e-commerce
|
||||||
operations. You agree to use the platform only for lawful
|
operations. You agree to use the platform only for lawful
|
||||||
purposes and in compliance with all applicable laws and
|
purposes and in compliance with all applicable laws and
|
||||||
regulations.
|
regulations.
|
||||||
@ -51,13 +51,13 @@ export default function TermsOfServicePage() {
|
|||||||
3. Third-Party Integrations
|
3. Third-Party Integrations
|
||||||
</h2>
|
</h2>
|
||||||
<p className="mb-2">
|
<p className="mb-2">
|
||||||
JUNOHUB integrates with third-party services such as e-commerce
|
JUNO integrates with third-party services such as e-commerce
|
||||||
platforms and email providers (including Gmail). Your use of
|
platforms and email providers (including Gmail). Your use of
|
||||||
those services is also governed by their respective terms and
|
those services is also governed by their respective terms and
|
||||||
policies.
|
policies.
|
||||||
</p>
|
</p>
|
||||||
<p>
|
<p>
|
||||||
By connecting these services, you authorize JUNOHUB to access
|
By connecting these services, you authorize JUNO to access
|
||||||
and process data from them as necessary to provide in-app
|
and process data from them as necessary to provide in-app
|
||||||
features, subject to our{" "}
|
features, subject to our{" "}
|
||||||
<Link
|
<Link
|
||||||
@ -75,14 +75,14 @@ export default function TermsOfServicePage() {
|
|||||||
4. Data and Content
|
4. Data and Content
|
||||||
</h2>
|
</h2>
|
||||||
<p className="mb-2">
|
<p className="mb-2">
|
||||||
You retain ownership of your data and content. By using JUNOHUB,
|
You retain ownership of your data and content. By using JUNO,
|
||||||
you grant us a limited license to process that data solely to
|
you grant us a limited license to process that data solely to
|
||||||
operate and improve the service.
|
operate and improve the service.
|
||||||
</p>
|
</p>
|
||||||
<p>
|
<p>
|
||||||
You are responsible for ensuring that you have all necessary
|
You are responsible for ensuring that you have all necessary
|
||||||
rights and permissions to use and process any data you connect
|
rights and permissions to use and process any data you connect
|
||||||
to JUNOHUB.
|
to JUNO.
|
||||||
</p>
|
</p>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
@ -91,7 +91,7 @@ export default function TermsOfServicePage() {
|
|||||||
5. Service Availability
|
5. Service Availability
|
||||||
</h2>
|
</h2>
|
||||||
<p>
|
<p>
|
||||||
We aim to provide a reliable and performant service, but JUNOHUB
|
We aim to provide a reliable and performant service, but JUNO
|
||||||
is provided on an "as is" and "as available"
|
is provided on an "as is" and "as available"
|
||||||
basis. We may modify, suspend, or discontinue features from time
|
basis. We may modify, suspend, or discontinue features from time
|
||||||
to time.
|
to time.
|
||||||
@ -103,7 +103,7 @@ export default function TermsOfServicePage() {
|
|||||||
6. Limitation of Liability
|
6. Limitation of Liability
|
||||||
</h2>
|
</h2>
|
||||||
<p>
|
<p>
|
||||||
To the maximum extent permitted by law, JUNOHUB will not be
|
To the maximum extent permitted by law, JUNO will not be
|
||||||
liable for indirect, incidental, special, consequential, or
|
liable for indirect, incidental, special, consequential, or
|
||||||
punitive damages, or any loss of profits or revenues arising out
|
punitive damages, or any loss of profits or revenues arising out
|
||||||
of or related to your use of the service.
|
of or related to your use of the service.
|
||||||
@ -117,7 +117,7 @@ export default function TermsOfServicePage() {
|
|||||||
<p>
|
<p>
|
||||||
We may update these Terms from time to time. If we make
|
We may update these Terms from time to time. If we make
|
||||||
material changes, we will provide notice by appropriate means.
|
material changes, we will provide notice by appropriate means.
|
||||||
Your continued use of JUNOHUB after the changes become
|
Your continued use of JUNO after the changes become
|
||||||
effective constitutes your acceptance of the updated Terms.
|
effective constitutes your acceptance of the updated Terms.
|
||||||
</p>
|
</p>
|
||||||
</section>
|
</section>
|
||||||
@ -128,7 +128,7 @@ export default function TermsOfServicePage() {
|
|||||||
</h2>
|
</h2>
|
||||||
<p>
|
<p>
|
||||||
If you have any questions about these Terms, please contact us
|
If you have any questions about these Terms, please contact us
|
||||||
using the support email provided in your JUNOHUB account or
|
using the support email provided in your JUNO account or
|
||||||
documentation.
|
documentation.
|
||||||
</p>
|
</p>
|
||||||
</section>
|
</section>
|
||||||
@ -136,13 +136,13 @@ export default function TermsOfServicePage() {
|
|||||||
<section className="pt-4 border-t border-white/10 mt-6 text-xs text-slate-400">
|
<section className="pt-4 border-t border-white/10 mt-6 text-xs text-slate-400">
|
||||||
<p>
|
<p>
|
||||||
These Terms of Service are provided for informational purposes
|
These Terms of Service are provided for informational purposes
|
||||||
and may be updated from time to time as JUNOHUB evolves.
|
and may be updated from time to time as JUNO evolves.
|
||||||
</p>
|
</p>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<footer className="mt-8 flex flex-wrap items-center justify-between gap-2 text-xs text-slate-400">
|
<footer className="mt-8 flex flex-wrap items-center justify-between gap-2 text-xs text-slate-400">
|
||||||
<span>JUNOHUB Systems © {new Date().getFullYear()}</span>
|
<span>JUNO Systems © {new Date().getFullYear()}</span>
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
<Link
|
<Link
|
||||||
href="/privacy-policy"
|
href="/privacy-policy"
|
||||||
|
|||||||
@ -96,7 +96,7 @@ export default function VerifyEmailPage() {
|
|||||||
<div className="w-12 h-12 rounded-2xl bg-indigo-500/40 border border-white/20 flex items-center justify-center backdrop-blur-sm">
|
<div className="w-12 h-12 rounded-2xl bg-indigo-500/40 border border-white/20 flex items-center justify-center backdrop-blur-sm">
|
||||||
<Zap className="w-6 h-6 text-white fill-white" />
|
<Zap className="w-6 h-6 text-white fill-white" />
|
||||||
</div>
|
</div>
|
||||||
<h1 className="text-2xl font-bold tracking-tight text-white">JUNOHUB</h1>
|
<h1 className="text-2xl font-bold tracking-tight text-white">JUNO</h1>
|
||||||
</div>
|
</div>
|
||||||
<h2 className="text-2xl sm:text-3xl font-extrabold tracking-tight text-white mb-2">
|
<h2 className="text-2xl sm:text-3xl font-extrabold tracking-tight text-white mb-2">
|
||||||
Verify <span className="text-indigo-400">Email</span>
|
Verify <span className="text-indigo-400">Email</span>
|
||||||
|
|||||||
@ -69,7 +69,7 @@ export function AdminSidebar() {
|
|||||||
<Radar className="w-5 h-5 text-white" />
|
<Radar className="w-5 h-5 text-white" />
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col">
|
<div className="flex flex-col">
|
||||||
<h1 className="text-xl font-bold tracking-tight">JUNOHUB</h1>
|
<h1 className="text-xl font-bold tracking-tight">JUNO</h1>
|
||||||
<span className="text-[11px] uppercase tracking-widest text-slate-400 font-semibold">
|
<span className="text-[11px] uppercase tracking-widest text-slate-400 font-semibold">
|
||||||
Admin Portal
|
Admin Portal
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@ -97,7 +97,7 @@ export function MobileSidebar() {
|
|||||||
)}>
|
)}>
|
||||||
<Image
|
<Image
|
||||||
src="/logo.png"
|
src="/logo.png"
|
||||||
alt="JUNOHUB Logo"
|
alt="JUNO Logo"
|
||||||
width={36}
|
width={36}
|
||||||
height={36}
|
height={36}
|
||||||
className="object-cover"
|
className="object-cover"
|
||||||
@ -106,7 +106,7 @@ export function MobileSidebar() {
|
|||||||
<h1 className={cn(
|
<h1 className={cn(
|
||||||
"text-xl font-bold tracking-tight",
|
"text-xl font-bold tracking-tight",
|
||||||
isLight ? "text-slate-900" : "text-white"
|
isLight ? "text-slate-900" : "text-white"
|
||||||
)}>JUNOHUB</h1>
|
)}>JUNO</h1>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={() => setIsOpen(false)}
|
onClick={() => setIsOpen(false)}
|
||||||
|
|||||||
@ -74,13 +74,13 @@ export function MobileSupplierSidebar() {
|
|||||||
<div className="w-9 h-9 rounded-xl overflow-hidden flex items-center justify-center shadow-md border border-white/10">
|
<div className="w-9 h-9 rounded-xl overflow-hidden flex items-center justify-center shadow-md border border-white/10">
|
||||||
<Image
|
<Image
|
||||||
src="/logo.png"
|
src="/logo.png"
|
||||||
alt="JUNOHUB Logo"
|
alt="JUNO Logo"
|
||||||
width={36}
|
width={36}
|
||||||
height={36}
|
height={36}
|
||||||
className="object-cover"
|
className="object-cover"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<h1 className="text-xl font-bold tracking-tight text-white">JUNOHUB</h1>
|
<h1 className="text-xl font-bold tracking-tight text-white">JUNO</h1>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={() => setIsOpen(false)}
|
onClick={() => setIsOpen(false)}
|
||||||
|
|||||||
@ -107,13 +107,13 @@ export function Sidebar({ showLogo = true, className }: SidebarProps) {
|
|||||||
)}>
|
)}>
|
||||||
<Image
|
<Image
|
||||||
src="/logo.png"
|
src="/logo.png"
|
||||||
alt="JUNOHUB Logo"
|
alt="JUNO Logo"
|
||||||
width={40}
|
width={40}
|
||||||
height={40}
|
height={40}
|
||||||
className="object-cover"
|
className="object-cover"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<h1 className={cn("text-xl font-bold tracking-tight", isLight ? "text-slate-900" : "text-white")}>JUNOHUB</h1>
|
<h1 className={cn("text-xl font-bold tracking-tight", isLight ? "text-slate-900" : "text-white")}>JUNO</h1>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="flex-1 px-3 space-y-1 overflow-y-auto">
|
<div className="flex-1 px-3 space-y-1 overflow-y-auto">
|
||||||
|
|||||||
@ -1,15 +1,23 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { useEffect } from 'react'
|
import { useEffect } from "react"
|
||||||
import Lenis from 'lenis'
|
import { usePathname } from "next/navigation"
|
||||||
|
import Lenis from "lenis"
|
||||||
|
|
||||||
export function SmoothScroll({ children }: { children: React.ReactNode }) {
|
export function SmoothScroll({ children }: { children: React.ReactNode }) {
|
||||||
|
const pathname = usePathname()
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
// Disable Lenis on app-style dashboards so native scrolling works
|
||||||
|
if (pathname.startsWith("/dashboard") || pathname.startsWith("/supplier")) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
const lenis = new Lenis({
|
const lenis = new Lenis({
|
||||||
duration: 1.2,
|
duration: 1.2,
|
||||||
easing: (t) => Math.min(1, 1.001 - Math.pow(2, -10 * t)),
|
easing: (t) => Math.min(1, 1.001 - Math.pow(2, -10 * t)),
|
||||||
orientation: 'vertical',
|
orientation: "vertical",
|
||||||
gestureOrientation: 'vertical',
|
gestureOrientation: "vertical",
|
||||||
smoothWheel: true,
|
smoothWheel: true,
|
||||||
wheelMultiplier: 1,
|
wheelMultiplier: 1,
|
||||||
touchMultiplier: 2,
|
touchMultiplier: 2,
|
||||||
@ -26,7 +34,7 @@ export function SmoothScroll({ children }: { children: React.ReactNode }) {
|
|||||||
return () => {
|
return () => {
|
||||||
lenis.destroy()
|
lenis.destroy()
|
||||||
}
|
}
|
||||||
}, [])
|
}, [pathname])
|
||||||
|
|
||||||
return <>{children}</>
|
return <>{children}</>
|
||||||
}
|
}
|
||||||
|
|||||||
@ -59,13 +59,13 @@ export function SupplierSidebar({ showLogo = true }: SupplierSidebarProps) {
|
|||||||
<div className="w-9 h-9 rounded-xl overflow-hidden flex items-center justify-center mr-3 shadow-md border border-white/10">
|
<div className="w-9 h-9 rounded-xl overflow-hidden flex items-center justify-center mr-3 shadow-md border border-white/10">
|
||||||
<Image
|
<Image
|
||||||
src="/logo.png"
|
src="/logo.png"
|
||||||
alt="JUNOHUB Logo"
|
alt="JUNO Logo"
|
||||||
width={36}
|
width={36}
|
||||||
height={36}
|
height={36}
|
||||||
className="object-cover"
|
className="object-cover"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<h1 className="text-xl font-bold tracking-tight">JUNOHUB</h1>
|
<h1 className="text-xl font-bold tracking-tight">JUNO</h1>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="flex-1 px-3 space-y-1 overflow-y-auto">
|
<div className="flex-1 px-3 space-y-1 overflow-y-auto">
|
||||||
|
|||||||
@ -41,7 +41,7 @@ export default function DashboardSidebar({
|
|||||||
<div className="w-10 h-10 rounded-2xl bg-indigo-600 flex items-center justify-center shadow-lg shadow-indigo-100">
|
<div className="w-10 h-10 rounded-2xl bg-indigo-600 flex items-center justify-center shadow-lg shadow-indigo-100">
|
||||||
<Layout className="w-6 h-6 text-white" />
|
<Layout className="w-6 h-6 text-white" />
|
||||||
</div>
|
</div>
|
||||||
<span className="font-extrabold text-2xl tracking-tighter text-slate-900">JUNOHUB</span>
|
<span className="font-extrabold text-2xl tracking-tighter text-slate-900">JUNO</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@ -82,7 +82,7 @@ export function LandingFeatures() {
|
|||||||
transition={{ delay: 0.2 }}
|
transition={{ delay: 0.2 }}
|
||||||
className="text-slate-400 max-w-2xl mx-auto font-medium"
|
className="text-slate-400 max-w-2xl mx-auto font-medium"
|
||||||
>
|
>
|
||||||
We built JUNOHUB to be the ultimate operating system for modern commerce.
|
We built JUNO to be the ultimate operating system for modern commerce.
|
||||||
Fast, secure, and beautiful.
|
Fast, secure, and beautiful.
|
||||||
</motion.p>
|
</motion.p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -18,13 +18,13 @@ export function LandingFooter() {
|
|||||||
<div className="w-10 h-10 rounded-xl bg-indigo-600/10 border border-white/10 flex items-center justify-center backdrop-blur-sm overflow-hidden shadow-lg shadow-indigo-500/20 group-hover:scale-110 transition-transform">
|
<div className="w-10 h-10 rounded-xl bg-indigo-600/10 border border-white/10 flex items-center justify-center backdrop-blur-sm overflow-hidden shadow-lg shadow-indigo-500/20 group-hover:scale-110 transition-transform">
|
||||||
<Image
|
<Image
|
||||||
src="/logo.png"
|
src="/logo.png"
|
||||||
alt="JUNOHUB Logo"
|
alt="JUNO Logo"
|
||||||
width={40}
|
width={40}
|
||||||
height={40}
|
height={40}
|
||||||
className="object-cover"
|
className="object-cover"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<span className="text-xl font-black tracking-tighter text-white">JUNOHUB</span>
|
<span className="text-xl font-black tracking-titter text-white">JUNO</span>
|
||||||
</Link>
|
</Link>
|
||||||
<p className="text-slate-300 font-medium mb-8">
|
<p className="text-slate-300 font-medium mb-8">
|
||||||
The next generation of supply chain connectivity. Secure, encrypted, and real-time.
|
The next generation of supply chain connectivity. Secure, encrypted, and real-time.
|
||||||
@ -133,7 +133,7 @@ export function LandingFooter() {
|
|||||||
|
|
||||||
<div className="pt-8 border-t border-white/5 flex flex-col md:flex-row items-center justify-between gap-4">
|
<div className="pt-8 border-t border-white/5 flex flex-col md:flex-row items-center justify-between gap-4">
|
||||||
<p className="text-sm text-slate-400 font-medium">
|
<p className="text-sm text-slate-400 font-medium">
|
||||||
© 2026 JUNOHUB. All rights reserved.
|
© 2026 JUNO. All rights reserved.
|
||||||
</p>
|
</p>
|
||||||
<div className="flex items-center gap-6">
|
<div className="flex items-center gap-6">
|
||||||
<Link href="/terms-of-service" className="text-xs text-slate-400 hover:text-indigo-300 font-bold uppercase tracking-widest">Terms</Link>
|
<Link href="/terms-of-service" className="text-xs text-slate-400 hover:text-indigo-300 font-bold uppercase tracking-widest">Terms</Link>
|
||||||
|
|||||||
@ -49,13 +49,13 @@ export function LandingNavbar({ isLoggedIn }: LandingNavbarProps) {
|
|||||||
<div className="w-10 h-10 rounded-xl bg-indigo-600/10 border border-white/10 flex items-center justify-center backdrop-blur-sm overflow-hidden shadow-lg shadow-indigo-500/20 group-hover:scale-110 transition-transform">
|
<div className="w-10 h-10 rounded-xl bg-indigo-600/10 border border-white/10 flex items-center justify-center backdrop-blur-sm overflow-hidden shadow-lg shadow-indigo-500/20 group-hover:scale-110 transition-transform">
|
||||||
<Image
|
<Image
|
||||||
src="/logo.png"
|
src="/logo.png"
|
||||||
alt="JUNOHUB Logo"
|
alt="JUNO Logo"
|
||||||
width={40}
|
width={40}
|
||||||
height={40}
|
height={40}
|
||||||
className="object-cover"
|
className="object-cover"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<span className="text-xl font-black tracking-tighter text-white">JUNOHUB</span>
|
<span className="text-xl font-black tracking-tighter text-white">JUNO</span>
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
{/* Desktop Nav */}
|
{/* Desktop Nav */}
|
||||||
|
|||||||
@ -99,7 +99,7 @@ export function MobileFeatures() {
|
|||||||
<div className="mt-20 p-8 rounded-[2.5rem] bg-gradient-to-br from-indigo-600 to-purple-600 text-center relative overflow-hidden">
|
<div className="mt-20 p-8 rounded-[2.5rem] bg-gradient-to-br from-indigo-600 to-purple-600 text-center relative overflow-hidden">
|
||||||
<div className="absolute top-0 right-0 w-32 h-32 bg-white/10 blur-[40px] rounded-full" />
|
<div className="absolute top-0 right-0 w-32 h-32 bg-white/10 blur-[40px] rounded-full" />
|
||||||
<h3 className="text-2xl font-black text-white mb-4 uppercase italic">Ready to sync?</h3>
|
<h3 className="text-2xl font-black text-white mb-4 uppercase italic">Ready to sync?</h3>
|
||||||
<p className="text-indigo-100 text-sm font-medium mb-8 opacity-90">Join the thousands of vendors scaling with JUNOHUB.</p>
|
<p className="text-indigo-100 text-sm font-medium mb-8 opacity-90">Join the thousands of vendors scaling with JUNO.</p>
|
||||||
<Link href="/signup" className="inline-flex items-center gap-2 px-8 py-4 bg-white text-indigo-600 rounded-2xl font-black text-sm uppercase tracking-wider shadow-lg">
|
<Link href="/signup" className="inline-flex items-center gap-2 px-8 py-4 bg-white text-indigo-600 rounded-2xl font-black text-sm uppercase tracking-wider shadow-lg">
|
||||||
Get Started <ArrowRight className="w-4 h-4" />
|
Get Started <ArrowRight className="w-4 h-4" />
|
||||||
</Link>
|
</Link>
|
||||||
|
|||||||
@ -2,7 +2,7 @@ import nodemailer from "nodemailer"
|
|||||||
import { logger } from "@/lib/logger"
|
import { logger } from "@/lib/logger"
|
||||||
import { getBaseUrl } from "@/lib/utils"
|
import { getBaseUrl } from "@/lib/utils"
|
||||||
|
|
||||||
/** Returns the absolute URL for the JUNOHUB logo (for use in email templates) */
|
/** Returns the absolute URL for the JUNO logo (for use in email templates) */
|
||||||
export function getEmailLogoUrl(): string {
|
export function getEmailLogoUrl(): string {
|
||||||
return `${getBaseUrl()}/icon.png`
|
return `${getBaseUrl()}/icon.png`
|
||||||
}
|
}
|
||||||
@ -45,7 +45,7 @@ export async function sendEmail({ to, subject, html }: EmailOptions) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const info = await transporter.sendMail({
|
const info = await transporter.sendMail({
|
||||||
from: `"JUNOHUB" <${process.env.SMTP_USER}>`,
|
from: `"JUNO" <${process.env.SMTP_USER}>`,
|
||||||
to,
|
to,
|
||||||
subject,
|
subject,
|
||||||
html,
|
html,
|
||||||
@ -74,7 +74,7 @@ export function generateInvitationEmailHtml(
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>Supplier Invitation - JUNOHUB</title>
|
<title>Supplier Invitation - JUNO</title>
|
||||||
</head>
|
</head>
|
||||||
<body style="margin: 0; padding: 0; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; background-color: #0a0e1a;">
|
<body style="margin: 0; padding: 0; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; background-color: #0a0e1a;">
|
||||||
<table role="presentation" style="width: 100%; border-collapse: collapse;">
|
<table role="presentation" style="width: 100%; border-collapse: collapse;">
|
||||||
@ -86,8 +86,8 @@ export function generateInvitationEmailHtml(
|
|||||||
<td style="padding: 40px 40px 30px; background: linear-gradient(135deg, rgba(79, 70, 229, 0.2) 0%, rgba(99, 102, 241, 0.1) 100%); border-bottom: 1px solid rgba(255, 255, 255, 0.1);">
|
<td style="padding: 40px 40px 30px; background: linear-gradient(135deg, rgba(79, 70, 229, 0.2) 0%, rgba(99, 102, 241, 0.1) 100%); border-bottom: 1px solid rgba(255, 255, 255, 0.1);">
|
||||||
<div style="text-align: center;">
|
<div style="text-align: center;">
|
||||||
<div style="display: inline-flex; align-items: center; justify-content: center; gap: 12px; margin-bottom: 16px;">
|
<div style="display: inline-flex; align-items: center; justify-content: center; gap: 12px; margin-bottom: 16px;">
|
||||||
<img src="${getEmailLogoUrl()}" alt="JUNOHUB" width="48" height="48" style="display: block; border-radius: 12px; object-fit: contain;" />
|
<img src="${getEmailLogoUrl()}" alt="JUNO" width="48" height="48" style="display: block; border-radius: 12px; object-fit: contain;" />
|
||||||
<h1 style="margin: 0; font-size: 28px; font-weight: 800; color: #ffffff; letter-spacing: -0.5px;">JUNOHUB</h1>
|
<h1 style="margin: 0; font-size: 28px; font-weight: 800; color: #ffffff; letter-spacing: -0.5px;">JUNO</h1>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
@ -103,7 +103,7 @@ export function generateInvitationEmailHtml(
|
|||||||
Hello ${supplierName || "Supplier"},
|
Hello ${supplierName || "Supplier"},
|
||||||
</p>
|
</p>
|
||||||
<p style="margin: 0 0 24px; font-size: 16px; line-height: 1.6; color: #cbd5e1;">
|
<p style="margin: 0 0 24px; font-size: 16px; line-height: 1.6; color: #cbd5e1;">
|
||||||
<strong style="color: #ffffff;">${vendorCompanyName}</strong> has invited you to connect on JUNOHUB. They're interested in partnering with you and would like to explore your product catalog.
|
<strong style="color: #ffffff;">${vendorCompanyName}</strong> has invited you to connect on JUNO. They're interested in partnering with you and would like to explore your product catalog.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<!-- Invitation Token Box -->
|
<!-- Invitation Token Box -->
|
||||||
@ -145,7 +145,7 @@ export function generateInvitationEmailHtml(
|
|||||||
This invitation will expire in 7 days.
|
This invitation will expire in 7 days.
|
||||||
</p>
|
</p>
|
||||||
<p style="margin: 0; font-size: 12px; color: #475569; text-align: center;">
|
<p style="margin: 0; font-size: 12px; color: #475569; text-align: center;">
|
||||||
© 2026 JUNOHUB Systems. All rights reserved.
|
© 2026 JUNO Systems. All rights reserved.
|
||||||
</p>
|
</p>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|||||||
@ -185,7 +185,7 @@ ${safeSnippet || "No additional message content was detected."}
|
|||||||
</p>
|
</p>
|
||||||
<p style="margin: 0; color: #e5e7eb;">
|
<p style="margin: 0; color: #e5e7eb;">
|
||||||
Best regards,<br/>
|
Best regards,<br/>
|
||||||
<span style="color: #a5b4fc;">JUNOHUB Support Team</span>
|
<span style="color: #a5b4fc;">JUNO Support Team</span>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user