Compare commits
14 Commits
feat/auto-
...
feat/wordp
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
74957a9ec5 | ||
|
|
2d035c46cf | ||
|
|
53445fe72a | ||
|
|
37cc8956c5 | ||
|
|
197c82f921 | ||
|
|
2c52493a9c | ||
|
|
2ee2ba6b8c | ||
|
|
bafcf1694a | ||
|
|
95792aab15 | ||
|
|
38ae2c3a3e | ||
|
|
249d3c1b7f | ||
|
|
9647f94f89 | ||
|
|
afc288d2cf | ||
|
|
df01ce6aad |
@@ -45,6 +45,31 @@ steps:
|
|||||||
when:
|
when:
|
||||||
event: pull_request
|
event: pull_request
|
||||||
|
|
||||||
|
# ===========================================
|
||||||
|
# AUTO-MERGE: Merge PR after all checks pass
|
||||||
|
# ===========================================
|
||||||
|
auto-merge:
|
||||||
|
image: alpine:latest
|
||||||
|
environment:
|
||||||
|
GITEA_TOKEN:
|
||||||
|
from_secret: gitea_token
|
||||||
|
commands:
|
||||||
|
- apk add --no-cache curl
|
||||||
|
- |
|
||||||
|
echo "Merging PR #${CI_COMMIT_PULL_REQUEST}..."
|
||||||
|
curl -s -X POST \
|
||||||
|
-H "Authorization: token $GITEA_TOKEN" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"Do":"merge"}' \
|
||||||
|
"https://code.cannabrands.app/api/v1/repos/Creationshop/dispensary-scraper/pulls/${CI_COMMIT_PULL_REQUEST}/merge"
|
||||||
|
depends_on:
|
||||||
|
- typecheck-backend
|
||||||
|
- typecheck-cannaiq
|
||||||
|
- typecheck-findadispo
|
||||||
|
- typecheck-findagram
|
||||||
|
when:
|
||||||
|
event: pull_request
|
||||||
|
|
||||||
# ===========================================
|
# ===========================================
|
||||||
# MASTER DEPLOY: Parallel Docker builds
|
# MASTER DEPLOY: Parallel Docker builds
|
||||||
# ===========================================
|
# ===========================================
|
||||||
@@ -64,11 +89,7 @@ steps:
|
|||||||
from_secret: registry_password
|
from_secret: registry_password
|
||||||
platforms: linux/amd64
|
platforms: linux/amd64
|
||||||
provenance: false
|
provenance: false
|
||||||
build_args:
|
build_args: APP_BUILD_VERSION=${CI_COMMIT_SHA:0:8},APP_GIT_SHA=${CI_COMMIT_SHA},APP_BUILD_TIME=${CI_PIPELINE_CREATED},CONTAINER_IMAGE_TAG=${CI_COMMIT_SHA:0:8}
|
||||||
- APP_BUILD_VERSION=${CI_COMMIT_SHA}
|
|
||||||
- APP_GIT_SHA=${CI_COMMIT_SHA}
|
|
||||||
- APP_BUILD_TIME=${CI_PIPELINE_CREATED}
|
|
||||||
- CONTAINER_IMAGE_TAG=${CI_COMMIT_SHA:0:8}
|
|
||||||
depends_on: []
|
depends_on: []
|
||||||
when:
|
when:
|
||||||
branch: master
|
branch: master
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ FROM code.cannabrands.app/creationshop/node:20-slim AS builder
|
|||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
COPY package*.json ./
|
COPY package*.json ./
|
||||||
RUN npm ci
|
RUN npm install
|
||||||
|
|
||||||
COPY . .
|
COPY . .
|
||||||
RUN npm run build
|
RUN npm run build
|
||||||
@@ -43,10 +43,13 @@ ENV PUPPETEER_EXECUTABLE_PATH=/usr/bin/chromium
|
|||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
COPY package*.json ./
|
COPY package*.json ./
|
||||||
RUN npm ci --omit=dev
|
RUN npm install --omit=dev
|
||||||
|
|
||||||
COPY --from=builder /app/dist ./dist
|
COPY --from=builder /app/dist ./dist
|
||||||
|
|
||||||
|
# Copy migrations for auto-migrate on startup
|
||||||
|
COPY migrations ./migrations
|
||||||
|
|
||||||
# Create local images directory for when MinIO is not configured
|
# Create local images directory for when MinIO is not configured
|
||||||
RUN mkdir -p /app/public/images/products
|
RUN mkdir -p /app/public/images/products
|
||||||
|
|
||||||
|
|||||||
@@ -90,7 +90,7 @@ export async function upsertStoreProducts(
|
|||||||
name_raw, brand_name_raw, category_raw, subcategory_raw,
|
name_raw, brand_name_raw, category_raw, subcategory_raw,
|
||||||
price_rec, price_med, price_rec_special, price_med_special,
|
price_rec, price_med, price_rec_special, price_med_special,
|
||||||
is_on_special, discount_percent,
|
is_on_special, discount_percent,
|
||||||
is_in_stock, stock_status,
|
is_in_stock, stock_status, stock_quantity, total_quantity_available,
|
||||||
thc_percent, cbd_percent,
|
thc_percent, cbd_percent,
|
||||||
image_url,
|
image_url,
|
||||||
first_seen_at, last_seen_at, updated_at
|
first_seen_at, last_seen_at, updated_at
|
||||||
@@ -99,9 +99,9 @@ export async function upsertStoreProducts(
|
|||||||
$5, $6, $7, $8,
|
$5, $6, $7, $8,
|
||||||
$9, $10, $11, $12,
|
$9, $10, $11, $12,
|
||||||
$13, $14,
|
$13, $14,
|
||||||
$15, $16,
|
$15, $16, $17, $17,
|
||||||
$17, $18,
|
$18, $19,
|
||||||
$19,
|
$20,
|
||||||
NOW(), NOW(), NOW()
|
NOW(), NOW(), NOW()
|
||||||
)
|
)
|
||||||
ON CONFLICT (dispensary_id, provider, provider_product_id)
|
ON CONFLICT (dispensary_id, provider, provider_product_id)
|
||||||
@@ -118,6 +118,8 @@ export async function upsertStoreProducts(
|
|||||||
discount_percent = EXCLUDED.discount_percent,
|
discount_percent = EXCLUDED.discount_percent,
|
||||||
is_in_stock = EXCLUDED.is_in_stock,
|
is_in_stock = EXCLUDED.is_in_stock,
|
||||||
stock_status = EXCLUDED.stock_status,
|
stock_status = EXCLUDED.stock_status,
|
||||||
|
stock_quantity = EXCLUDED.stock_quantity,
|
||||||
|
total_quantity_available = EXCLUDED.total_quantity_available,
|
||||||
thc_percent = EXCLUDED.thc_percent,
|
thc_percent = EXCLUDED.thc_percent,
|
||||||
cbd_percent = EXCLUDED.cbd_percent,
|
cbd_percent = EXCLUDED.cbd_percent,
|
||||||
image_url = EXCLUDED.image_url,
|
image_url = EXCLUDED.image_url,
|
||||||
@@ -141,6 +143,7 @@ export async function upsertStoreProducts(
|
|||||||
productPricing?.discountPercent,
|
productPricing?.discountPercent,
|
||||||
productAvailability?.inStock ?? true,
|
productAvailability?.inStock ?? true,
|
||||||
productAvailability?.stockStatus || 'unknown',
|
productAvailability?.stockStatus || 'unknown',
|
||||||
|
productAvailability?.quantity ?? null, // stock_quantity and total_quantity_available
|
||||||
// Clamp THC/CBD to valid percentage range (0-100) - some products report mg as %
|
// Clamp THC/CBD to valid percentage range (0-100) - some products report mg as %
|
||||||
product.thcPercent !== null && product.thcPercent <= 100 ? product.thcPercent : null,
|
product.thcPercent !== null && product.thcPercent <= 100 ? product.thcPercent : null,
|
||||||
product.cbdPercent !== null && product.cbdPercent <= 100 ? product.cbdPercent : null,
|
product.cbdPercent !== null && product.cbdPercent <= 100 ? product.cbdPercent : null,
|
||||||
|
|||||||
@@ -231,6 +231,34 @@ export function createAnalyticsV2Router(pool: Pool): Router {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /brand/:name/promotions
|
||||||
|
* Get brand promotional history - tracks specials, discounts, duration, and sales estimates
|
||||||
|
*
|
||||||
|
* Query params:
|
||||||
|
* - window: 7d|30d|90d (default: 90d)
|
||||||
|
* - state: state code filter (e.g., AZ)
|
||||||
|
* - category: category filter (e.g., Flower)
|
||||||
|
*/
|
||||||
|
router.get('/brand/:name/promotions', async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const brandName = decodeURIComponent(req.params.name);
|
||||||
|
const window = parseTimeWindow(req.query.window as string) || '90d';
|
||||||
|
const stateCode = req.query.state as string | undefined;
|
||||||
|
const category = req.query.category as string | undefined;
|
||||||
|
|
||||||
|
const result = await brandService.getBrandPromotionalHistory(brandName, {
|
||||||
|
window,
|
||||||
|
stateCode,
|
||||||
|
category,
|
||||||
|
});
|
||||||
|
res.json(result);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[AnalyticsV2] Brand promotions error:', error);
|
||||||
|
res.status(500).json({ error: 'Failed to fetch brand promotional history' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// ============================================================
|
// ============================================================
|
||||||
// CATEGORY ANALYTICS
|
// CATEGORY ANALYTICS
|
||||||
// ============================================================
|
// ============================================================
|
||||||
@@ -400,6 +428,31 @@ export function createAnalyticsV2Router(pool: Pool): Router {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /store/:id/quantity-changes
|
||||||
|
* Get quantity changes for a store (increases/decreases)
|
||||||
|
* Useful for estimating sales (decreases) or restocks (increases)
|
||||||
|
*
|
||||||
|
* Query params:
|
||||||
|
* - window: 7d|30d|90d (default: 7d)
|
||||||
|
* - direction: increase|decrease|all (default: all)
|
||||||
|
* - limit: number (default: 100)
|
||||||
|
*/
|
||||||
|
router.get('/store/:id/quantity-changes', async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const dispensaryId = parseInt(req.params.id);
|
||||||
|
const window = parseTimeWindow(req.query.window as string);
|
||||||
|
const direction = (req.query.direction as 'increase' | 'decrease' | 'all') || 'all';
|
||||||
|
const limit = req.query.limit ? parseInt(req.query.limit as string) : 100;
|
||||||
|
|
||||||
|
const result = await storeService.getQuantityChanges(dispensaryId, { window, direction, limit });
|
||||||
|
res.json(result);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[AnalyticsV2] Store quantity changes error:', error);
|
||||||
|
res.status(500).json({ error: 'Failed to fetch store quantity changes' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* GET /store/:id/inventory
|
* GET /store/:id/inventory
|
||||||
* Get store inventory composition
|
* Get store inventory composition
|
||||||
|
|||||||
@@ -27,8 +27,8 @@ router.get('/brands', async (req: Request, res: Response) => {
|
|||||||
array_agg(DISTINCT d.state) FILTER (WHERE d.state IS NOT NULL) as states,
|
array_agg(DISTINCT d.state) FILTER (WHERE d.state IS NOT NULL) as states,
|
||||||
COUNT(DISTINCT d.id) as store_count,
|
COUNT(DISTINCT d.id) as store_count,
|
||||||
COUNT(DISTINCT sp.id) as sku_count,
|
COUNT(DISTINCT sp.id) as sku_count,
|
||||||
ROUND(AVG(sp.price_rec)::numeric, 2) FILTER (WHERE sp.price_rec > 0) as avg_price_rec,
|
ROUND(AVG(sp.price_rec) FILTER (WHERE sp.price_rec > 0)::numeric, 2) as avg_price_rec,
|
||||||
ROUND(AVG(sp.price_med)::numeric, 2) FILTER (WHERE sp.price_med > 0) as avg_price_med
|
ROUND(AVG(sp.price_med) FILTER (WHERE sp.price_med > 0)::numeric, 2) as avg_price_med
|
||||||
FROM store_products sp
|
FROM store_products sp
|
||||||
JOIN dispensaries d ON sp.dispensary_id = d.id
|
JOIN dispensaries d ON sp.dispensary_id = d.id
|
||||||
WHERE sp.brand_name_raw IS NOT NULL AND sp.brand_name_raw != ''
|
WHERE sp.brand_name_raw IS NOT NULL AND sp.brand_name_raw != ''
|
||||||
@@ -154,10 +154,9 @@ router.get('/pricing', async (req: Request, res: Response) => {
|
|||||||
SELECT
|
SELECT
|
||||||
sp.category_raw as category,
|
sp.category_raw as category,
|
||||||
ROUND(AVG(sp.price_rec)::numeric, 2) as avg_price,
|
ROUND(AVG(sp.price_rec)::numeric, 2) as avg_price,
|
||||||
MIN(sp.price_rec) FILTER (WHERE sp.price_rec > 0) as min_price,
|
MIN(sp.price_rec) as min_price,
|
||||||
MAX(sp.price_rec) as max_price,
|
MAX(sp.price_rec) as max_price,
|
||||||
ROUND(PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY sp.price_rec)::numeric, 2)
|
ROUND(PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY sp.price_rec)::numeric, 2) as median_price,
|
||||||
FILTER (WHERE sp.price_rec > 0) as median_price,
|
|
||||||
COUNT(*) as product_count
|
COUNT(*) as product_count
|
||||||
FROM store_products sp
|
FROM store_products sp
|
||||||
WHERE sp.category_raw IS NOT NULL AND sp.price_rec > 0
|
WHERE sp.category_raw IS NOT NULL AND sp.price_rec > 0
|
||||||
@@ -169,7 +168,7 @@ router.get('/pricing', async (req: Request, res: Response) => {
|
|||||||
SELECT
|
SELECT
|
||||||
d.state,
|
d.state,
|
||||||
ROUND(AVG(sp.price_rec)::numeric, 2) as avg_price,
|
ROUND(AVG(sp.price_rec)::numeric, 2) as avg_price,
|
||||||
MIN(sp.price_rec) FILTER (WHERE sp.price_rec > 0) as min_price,
|
MIN(sp.price_rec) as min_price,
|
||||||
MAX(sp.price_rec) as max_price,
|
MAX(sp.price_rec) as max_price,
|
||||||
COUNT(DISTINCT sp.id) as product_count
|
COUNT(DISTINCT sp.id) as product_count
|
||||||
FROM store_products sp
|
FROM store_products sp
|
||||||
|
|||||||
@@ -183,8 +183,8 @@ router.post('/test-all', requireRole('superadmin', 'admin'), async (req, res) =>
|
|||||||
return res.status(400).json({ error: 'Concurrency must be between 1 and 50' });
|
return res.status(400).json({ error: 'Concurrency must be between 1 and 50' });
|
||||||
}
|
}
|
||||||
|
|
||||||
const jobId = await createProxyTestJob(mode, concurrency);
|
const { jobId, totalProxies } = await createProxyTestJob(mode, concurrency);
|
||||||
res.json({ jobId, mode, concurrency, message: `Proxy test job started (mode: ${mode}, concurrency: ${concurrency})` });
|
res.json({ jobId, total: totalProxies, mode, concurrency, message: `Proxy test job started (mode: ${mode}, concurrency: ${concurrency})` });
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
console.error('Error starting proxy test job:', error);
|
console.error('Error starting proxy test job:', error);
|
||||||
res.status(500).json({ error: error.message || 'Failed to start proxy test job' });
|
res.status(500).json({ error: error.message || 'Failed to start proxy test job' });
|
||||||
@@ -195,8 +195,8 @@ router.post('/test-all', requireRole('superadmin', 'admin'), async (req, res) =>
|
|||||||
router.post('/test-failed', requireRole('superadmin', 'admin'), async (req, res) => {
|
router.post('/test-failed', requireRole('superadmin', 'admin'), async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const concurrency = parseInt(req.query.concurrency as string) || 10;
|
const concurrency = parseInt(req.query.concurrency as string) || 10;
|
||||||
const jobId = await createProxyTestJob('failed', concurrency);
|
const { jobId, totalProxies } = await createProxyTestJob('failed', concurrency);
|
||||||
res.json({ jobId, mode: 'failed', concurrency, message: 'Retesting failed proxies...' });
|
res.json({ jobId, total: totalProxies, mode: 'failed', concurrency, message: 'Retesting failed proxies...' });
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
console.error('Error starting failed proxy test:', error);
|
console.error('Error starting failed proxy test:', error);
|
||||||
res.status(500).json({ error: error.message || 'Failed to start proxy test job' });
|
res.status(500).json({ error: error.message || 'Failed to start proxy test job' });
|
||||||
|
|||||||
@@ -14,23 +14,36 @@ router.get('/', async (req: AuthRequest, res) => {
|
|||||||
try {
|
try {
|
||||||
const { search, domain } = req.query;
|
const { search, domain } = req.query;
|
||||||
|
|
||||||
let query = `
|
// Check which columns exist (schema-tolerant)
|
||||||
SELECT id, email, role, first_name, last_name, phone, domain, created_at, updated_at
|
const columnsResult = await pool.query(`
|
||||||
FROM users
|
SELECT column_name FROM information_schema.columns
|
||||||
WHERE 1=1
|
WHERE table_name = 'users' AND column_name IN ('first_name', 'last_name', 'phone', 'domain')
|
||||||
`;
|
`);
|
||||||
|
const existingColumns = new Set(columnsResult.rows.map((r: any) => r.column_name));
|
||||||
|
|
||||||
|
// Build column list based on what exists
|
||||||
|
const selectCols = ['id', 'email', 'role', 'created_at', 'updated_at'];
|
||||||
|
if (existingColumns.has('first_name')) selectCols.push('first_name');
|
||||||
|
if (existingColumns.has('last_name')) selectCols.push('last_name');
|
||||||
|
if (existingColumns.has('phone')) selectCols.push('phone');
|
||||||
|
if (existingColumns.has('domain')) selectCols.push('domain');
|
||||||
|
|
||||||
|
let query = `SELECT ${selectCols.join(', ')} FROM users WHERE 1=1`;
|
||||||
const params: any[] = [];
|
const params: any[] = [];
|
||||||
let paramIndex = 1;
|
let paramIndex = 1;
|
||||||
|
|
||||||
// Search by email, first_name, or last_name
|
// Search by email (and optionally first_name, last_name if they exist)
|
||||||
if (search && typeof search === 'string') {
|
if (search && typeof search === 'string') {
|
||||||
query += ` AND (email ILIKE $${paramIndex} OR first_name ILIKE $${paramIndex} OR last_name ILIKE $${paramIndex})`;
|
const searchClauses = ['email ILIKE $' + paramIndex];
|
||||||
|
if (existingColumns.has('first_name')) searchClauses.push('first_name ILIKE $' + paramIndex);
|
||||||
|
if (existingColumns.has('last_name')) searchClauses.push('last_name ILIKE $' + paramIndex);
|
||||||
|
query += ` AND (${searchClauses.join(' OR ')})`;
|
||||||
params.push(`%${search}%`);
|
params.push(`%${search}%`);
|
||||||
paramIndex++;
|
paramIndex++;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Filter by domain
|
// Filter by domain (if column exists)
|
||||||
if (domain && typeof domain === 'string') {
|
if (domain && typeof domain === 'string' && existingColumns.has('domain')) {
|
||||||
query += ` AND domain = $${paramIndex}`;
|
query += ` AND domain = $${paramIndex}`;
|
||||||
params.push(domain);
|
params.push(domain);
|
||||||
paramIndex++;
|
paramIndex++;
|
||||||
@@ -50,8 +63,22 @@ router.get('/', async (req: AuthRequest, res) => {
|
|||||||
router.get('/:id', async (req: AuthRequest, res) => {
|
router.get('/:id', async (req: AuthRequest, res) => {
|
||||||
try {
|
try {
|
||||||
const { id } = req.params;
|
const { id } = req.params;
|
||||||
|
|
||||||
|
// Check which columns exist (schema-tolerant)
|
||||||
|
const columnsResult = await pool.query(`
|
||||||
|
SELECT column_name FROM information_schema.columns
|
||||||
|
WHERE table_name = 'users' AND column_name IN ('first_name', 'last_name', 'phone', 'domain')
|
||||||
|
`);
|
||||||
|
const existingColumns = new Set(columnsResult.rows.map((r: any) => r.column_name));
|
||||||
|
|
||||||
|
const selectCols = ['id', 'email', 'role', 'created_at', 'updated_at'];
|
||||||
|
if (existingColumns.has('first_name')) selectCols.push('first_name');
|
||||||
|
if (existingColumns.has('last_name')) selectCols.push('last_name');
|
||||||
|
if (existingColumns.has('phone')) selectCols.push('phone');
|
||||||
|
if (existingColumns.has('domain')) selectCols.push('domain');
|
||||||
|
|
||||||
const result = await pool.query(`
|
const result = await pool.query(`
|
||||||
SELECT id, email, role, first_name, last_name, phone, domain, created_at, updated_at
|
SELECT ${selectCols.join(', ')}
|
||||||
FROM users
|
FROM users
|
||||||
WHERE id = $1
|
WHERE id = $1
|
||||||
`, [id]);
|
`, [id]);
|
||||||
|
|||||||
@@ -273,6 +273,29 @@ router.post('/deregister', async (req: Request, res: Response) => {
|
|||||||
*/
|
*/
|
||||||
router.get('/workers', async (req: Request, res: Response) => {
|
router.get('/workers', async (req: Request, res: Response) => {
|
||||||
try {
|
try {
|
||||||
|
// Check if worker_registry table exists
|
||||||
|
const tableCheck = await pool.query(`
|
||||||
|
SELECT EXISTS (
|
||||||
|
SELECT FROM information_schema.tables
|
||||||
|
WHERE table_name = 'worker_registry'
|
||||||
|
) as exists
|
||||||
|
`);
|
||||||
|
|
||||||
|
if (!tableCheck.rows[0].exists) {
|
||||||
|
// Return empty result if table doesn't exist yet
|
||||||
|
return res.json({
|
||||||
|
success: true,
|
||||||
|
workers: [],
|
||||||
|
summary: {
|
||||||
|
active_count: 0,
|
||||||
|
idle_count: 0,
|
||||||
|
offline_count: 0,
|
||||||
|
total_count: 0,
|
||||||
|
active_roles: 0
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const { status, role, include_terminated = 'false' } = req.query;
|
const { status, role, include_terminated = 'false' } = req.query;
|
||||||
|
|
||||||
let whereClause = include_terminated === 'true' ? 'WHERE 1=1' : "WHERE status != 'terminated'";
|
let whereClause = include_terminated === 'true' ? 'WHERE 1=1' : "WHERE status != 'terminated'";
|
||||||
|
|||||||
@@ -26,6 +26,8 @@ import {
|
|||||||
PenetrationDataPoint,
|
PenetrationDataPoint,
|
||||||
BrandMarketPosition,
|
BrandMarketPosition,
|
||||||
BrandRecVsMedFootprint,
|
BrandRecVsMedFootprint,
|
||||||
|
BrandPromotionalSummary,
|
||||||
|
BrandPromotionalEvent,
|
||||||
} from './types';
|
} from './types';
|
||||||
|
|
||||||
export class BrandPenetrationService {
|
export class BrandPenetrationService {
|
||||||
@@ -44,16 +46,17 @@ export class BrandPenetrationService {
|
|||||||
// Get current brand presence
|
// Get current brand presence
|
||||||
const currentResult = await this.pool.query(`
|
const currentResult = await this.pool.query(`
|
||||||
SELECT
|
SELECT
|
||||||
sp.brand_name,
|
sp.brand_name_raw AS brand_name,
|
||||||
COUNT(DISTINCT sp.dispensary_id) AS total_dispensaries,
|
COUNT(DISTINCT sp.dispensary_id) AS total_dispensaries,
|
||||||
COUNT(*) AS total_skus,
|
COUNT(*) AS total_skus,
|
||||||
ROUND(COUNT(*)::NUMERIC / NULLIF(COUNT(DISTINCT sp.dispensary_id), 0), 2) AS avg_skus_per_dispensary,
|
ROUND(COUNT(*)::NUMERIC / NULLIF(COUNT(DISTINCT sp.dispensary_id), 0), 2) AS avg_skus_per_dispensary,
|
||||||
ARRAY_AGG(DISTINCT s.code) FILTER (WHERE s.code IS NOT NULL) AS states_present
|
ARRAY_AGG(DISTINCT s.code) FILTER (WHERE s.code IS NOT NULL) AS states_present
|
||||||
FROM store_products sp
|
FROM store_products sp
|
||||||
LEFT JOIN states s ON s.id = sp.state_id
|
JOIN dispensaries d ON d.id = sp.dispensary_id
|
||||||
WHERE sp.brand_name = $1
|
LEFT JOIN states s ON s.id = d.state_id
|
||||||
|
WHERE sp.brand_name_raw = $1
|
||||||
AND sp.is_in_stock = TRUE
|
AND sp.is_in_stock = TRUE
|
||||||
GROUP BY sp.brand_name
|
GROUP BY sp.brand_name_raw
|
||||||
`, [brandName]);
|
`, [brandName]);
|
||||||
|
|
||||||
if (currentResult.rows.length === 0) {
|
if (currentResult.rows.length === 0) {
|
||||||
@@ -72,7 +75,7 @@ export class BrandPenetrationService {
|
|||||||
DATE(sps.captured_at) AS date,
|
DATE(sps.captured_at) AS date,
|
||||||
COUNT(DISTINCT sps.dispensary_id) AS dispensary_count
|
COUNT(DISTINCT sps.dispensary_id) AS dispensary_count
|
||||||
FROM store_product_snapshots sps
|
FROM store_product_snapshots sps
|
||||||
WHERE sps.brand_name = $1
|
WHERE sps.brand_name_raw = $1
|
||||||
AND sps.captured_at >= $2
|
AND sps.captured_at >= $2
|
||||||
AND sps.captured_at <= $3
|
AND sps.captured_at <= $3
|
||||||
AND sps.is_in_stock = TRUE
|
AND sps.is_in_stock = TRUE
|
||||||
@@ -123,8 +126,9 @@ export class BrandPenetrationService {
|
|||||||
COUNT(DISTINCT sp.dispensary_id) AS dispensary_count,
|
COUNT(DISTINCT sp.dispensary_id) AS dispensary_count,
|
||||||
COUNT(*) AS sku_count
|
COUNT(*) AS sku_count
|
||||||
FROM store_products sp
|
FROM store_products sp
|
||||||
JOIN states s ON s.id = sp.state_id
|
JOIN dispensaries d ON d.id = sp.dispensary_id
|
||||||
WHERE sp.brand_name = $1
|
JOIN states s ON s.id = d.state_id
|
||||||
|
WHERE sp.brand_name_raw = $1
|
||||||
AND sp.is_in_stock = TRUE
|
AND sp.is_in_stock = TRUE
|
||||||
GROUP BY s.code, s.name, s.recreational_legal, s.medical_legal
|
GROUP BY s.code, s.name, s.recreational_legal, s.medical_legal
|
||||||
),
|
),
|
||||||
@@ -133,7 +137,8 @@ export class BrandPenetrationService {
|
|||||||
s.code AS state_code,
|
s.code AS state_code,
|
||||||
COUNT(DISTINCT sp.dispensary_id) AS total_dispensaries
|
COUNT(DISTINCT sp.dispensary_id) AS total_dispensaries
|
||||||
FROM store_products sp
|
FROM store_products sp
|
||||||
JOIN states s ON s.id = sp.state_id
|
JOIN dispensaries d ON d.id = sp.dispensary_id
|
||||||
|
JOIN states s ON s.id = d.state_id
|
||||||
WHERE sp.is_in_stock = TRUE
|
WHERE sp.is_in_stock = TRUE
|
||||||
GROUP BY s.code
|
GROUP BY s.code
|
||||||
)
|
)
|
||||||
@@ -169,7 +174,7 @@ export class BrandPenetrationService {
|
|||||||
let filters = '';
|
let filters = '';
|
||||||
|
|
||||||
if (options.category) {
|
if (options.category) {
|
||||||
filters += ` AND sp.category = $${paramIdx}`;
|
filters += ` AND sp.category_raw = $${paramIdx}`;
|
||||||
params.push(options.category);
|
params.push(options.category);
|
||||||
paramIdx++;
|
paramIdx++;
|
||||||
}
|
}
|
||||||
@@ -183,31 +188,33 @@ export class BrandPenetrationService {
|
|||||||
const result = await this.pool.query(`
|
const result = await this.pool.query(`
|
||||||
WITH brand_metrics AS (
|
WITH brand_metrics AS (
|
||||||
SELECT
|
SELECT
|
||||||
sp.brand_name,
|
sp.brand_name_raw AS brand_name,
|
||||||
sp.category,
|
sp.category_raw AS category,
|
||||||
s.code AS state_code,
|
s.code AS state_code,
|
||||||
COUNT(*) AS sku_count,
|
COUNT(*) AS sku_count,
|
||||||
COUNT(DISTINCT sp.dispensary_id) AS dispensary_count,
|
COUNT(DISTINCT sp.dispensary_id) AS dispensary_count,
|
||||||
AVG(sp.price_rec) AS avg_price
|
AVG(sp.price_rec) AS avg_price
|
||||||
FROM store_products sp
|
FROM store_products sp
|
||||||
JOIN states s ON s.id = sp.state_id
|
JOIN dispensaries d ON d.id = sp.dispensary_id
|
||||||
WHERE sp.brand_name = $1
|
JOIN states s ON s.id = d.state_id
|
||||||
|
WHERE sp.brand_name_raw = $1
|
||||||
AND sp.is_in_stock = TRUE
|
AND sp.is_in_stock = TRUE
|
||||||
AND sp.category IS NOT NULL
|
AND sp.category_raw IS NOT NULL
|
||||||
${filters}
|
${filters}
|
||||||
GROUP BY sp.brand_name, sp.category, s.code
|
GROUP BY sp.brand_name_raw, sp.category_raw, s.code
|
||||||
),
|
),
|
||||||
category_totals AS (
|
category_totals AS (
|
||||||
SELECT
|
SELECT
|
||||||
sp.category,
|
sp.category_raw AS category,
|
||||||
s.code AS state_code,
|
s.code AS state_code,
|
||||||
COUNT(*) AS total_skus,
|
COUNT(*) AS total_skus,
|
||||||
AVG(sp.price_rec) AS category_avg_price
|
AVG(sp.price_rec) AS category_avg_price
|
||||||
FROM store_products sp
|
FROM store_products sp
|
||||||
JOIN states s ON s.id = sp.state_id
|
JOIN dispensaries d ON d.id = sp.dispensary_id
|
||||||
|
JOIN states s ON s.id = d.state_id
|
||||||
WHERE sp.is_in_stock = TRUE
|
WHERE sp.is_in_stock = TRUE
|
||||||
AND sp.category IS NOT NULL
|
AND sp.category_raw IS NOT NULL
|
||||||
GROUP BY sp.category, s.code
|
GROUP BY sp.category_raw, s.code
|
||||||
)
|
)
|
||||||
SELECT
|
SELECT
|
||||||
bm.*,
|
bm.*,
|
||||||
@@ -243,8 +250,9 @@ export class BrandPenetrationService {
|
|||||||
COUNT(DISTINCT sp.dispensary_id) AS dispensary_count,
|
COUNT(DISTINCT sp.dispensary_id) AS dispensary_count,
|
||||||
ROUND(COUNT(*)::NUMERIC / NULLIF(COUNT(DISTINCT sp.dispensary_id), 0), 2) AS avg_skus
|
ROUND(COUNT(*)::NUMERIC / NULLIF(COUNT(DISTINCT sp.dispensary_id), 0), 2) AS avg_skus
|
||||||
FROM store_products sp
|
FROM store_products sp
|
||||||
JOIN states s ON s.id = sp.state_id
|
JOIN dispensaries d ON d.id = sp.dispensary_id
|
||||||
WHERE sp.brand_name = $1
|
JOIN states s ON s.id = d.state_id
|
||||||
|
WHERE sp.brand_name_raw = $1
|
||||||
AND sp.is_in_stock = TRUE
|
AND sp.is_in_stock = TRUE
|
||||||
AND s.recreational_legal = TRUE
|
AND s.recreational_legal = TRUE
|
||||||
),
|
),
|
||||||
@@ -255,8 +263,9 @@ export class BrandPenetrationService {
|
|||||||
COUNT(DISTINCT sp.dispensary_id) AS dispensary_count,
|
COUNT(DISTINCT sp.dispensary_id) AS dispensary_count,
|
||||||
ROUND(COUNT(*)::NUMERIC / NULLIF(COUNT(DISTINCT sp.dispensary_id), 0), 2) AS avg_skus
|
ROUND(COUNT(*)::NUMERIC / NULLIF(COUNT(DISTINCT sp.dispensary_id), 0), 2) AS avg_skus
|
||||||
FROM store_products sp
|
FROM store_products sp
|
||||||
JOIN states s ON s.id = sp.state_id
|
JOIN dispensaries d ON d.id = sp.dispensary_id
|
||||||
WHERE sp.brand_name = $1
|
JOIN states s ON s.id = d.state_id
|
||||||
|
WHERE sp.brand_name_raw = $1
|
||||||
AND sp.is_in_stock = TRUE
|
AND sp.is_in_stock = TRUE
|
||||||
AND s.medical_legal = TRUE
|
AND s.medical_legal = TRUE
|
||||||
AND (s.recreational_legal = FALSE OR s.recreational_legal IS NULL)
|
AND (s.recreational_legal = FALSE OR s.recreational_legal IS NULL)
|
||||||
@@ -311,23 +320,24 @@ export class BrandPenetrationService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (category) {
|
if (category) {
|
||||||
filters += ` AND sp.category = $${paramIdx}`;
|
filters += ` AND sp.category_raw = $${paramIdx}`;
|
||||||
params.push(category);
|
params.push(category);
|
||||||
paramIdx++;
|
paramIdx++;
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = await this.pool.query(`
|
const result = await this.pool.query(`
|
||||||
SELECT
|
SELECT
|
||||||
sp.brand_name,
|
sp.brand_name_raw AS brand_name,
|
||||||
COUNT(DISTINCT sp.dispensary_id) AS dispensary_count,
|
COUNT(DISTINCT sp.dispensary_id) AS dispensary_count,
|
||||||
COUNT(*) AS sku_count,
|
COUNT(*) AS sku_count,
|
||||||
COUNT(DISTINCT s.code) AS state_count
|
COUNT(DISTINCT s.code) AS state_count
|
||||||
FROM store_products sp
|
FROM store_products sp
|
||||||
LEFT JOIN states s ON s.id = sp.state_id
|
JOIN dispensaries d ON d.id = sp.dispensary_id
|
||||||
WHERE sp.brand_name IS NOT NULL
|
LEFT JOIN states s ON s.id = d.state_id
|
||||||
|
WHERE sp.brand_name_raw IS NOT NULL
|
||||||
AND sp.is_in_stock = TRUE
|
AND sp.is_in_stock = TRUE
|
||||||
${filters}
|
${filters}
|
||||||
GROUP BY sp.brand_name
|
GROUP BY sp.brand_name_raw
|
||||||
ORDER BY dispensary_count DESC, sku_count DESC
|
ORDER BY dispensary_count DESC, sku_count DESC
|
||||||
LIMIT $1
|
LIMIT $1
|
||||||
`, params);
|
`, params);
|
||||||
@@ -358,23 +368,23 @@ export class BrandPenetrationService {
|
|||||||
const result = await this.pool.query(`
|
const result = await this.pool.query(`
|
||||||
WITH start_counts AS (
|
WITH start_counts AS (
|
||||||
SELECT
|
SELECT
|
||||||
brand_name,
|
brand_name_raw AS brand_name,
|
||||||
COUNT(DISTINCT dispensary_id) AS dispensary_count
|
COUNT(DISTINCT dispensary_id) AS dispensary_count
|
||||||
FROM store_product_snapshots
|
FROM store_product_snapshots
|
||||||
WHERE captured_at >= $1 AND captured_at < $1 + INTERVAL '1 day'
|
WHERE captured_at >= $1 AND captured_at < $1 + INTERVAL '1 day'
|
||||||
AND brand_name IS NOT NULL
|
AND brand_name_raw IS NOT NULL
|
||||||
AND is_in_stock = TRUE
|
AND is_in_stock = TRUE
|
||||||
GROUP BY brand_name
|
GROUP BY brand_name_raw
|
||||||
),
|
),
|
||||||
end_counts AS (
|
end_counts AS (
|
||||||
SELECT
|
SELECT
|
||||||
brand_name,
|
brand_name_raw AS brand_name,
|
||||||
COUNT(DISTINCT dispensary_id) AS dispensary_count
|
COUNT(DISTINCT dispensary_id) AS dispensary_count
|
||||||
FROM store_product_snapshots
|
FROM store_product_snapshots
|
||||||
WHERE captured_at >= $2 - INTERVAL '1 day' AND captured_at <= $2
|
WHERE captured_at >= $2 - INTERVAL '1 day' AND captured_at <= $2
|
||||||
AND brand_name IS NOT NULL
|
AND brand_name_raw IS NOT NULL
|
||||||
AND is_in_stock = TRUE
|
AND is_in_stock = TRUE
|
||||||
GROUP BY brand_name
|
GROUP BY brand_name_raw
|
||||||
)
|
)
|
||||||
SELECT
|
SELECT
|
||||||
COALESCE(sc.brand_name, ec.brand_name) AS brand_name,
|
COALESCE(sc.brand_name, ec.brand_name) AS brand_name,
|
||||||
@@ -401,6 +411,225 @@ export class BrandPenetrationService {
|
|||||||
change_percent: row.change_percent ? parseFloat(row.change_percent) : 0,
|
change_percent: row.change_percent ? parseFloat(row.change_percent) : 0,
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get brand promotional history
|
||||||
|
*
|
||||||
|
* Tracks when products went on special, how long, what discount,
|
||||||
|
* and estimated quantity sold during the promotion.
|
||||||
|
*/
|
||||||
|
async getBrandPromotionalHistory(
|
||||||
|
brandName: string,
|
||||||
|
options: { window?: TimeWindow; customRange?: DateRange; stateCode?: string; category?: string } = {}
|
||||||
|
): Promise<BrandPromotionalSummary> {
|
||||||
|
const { window = '90d', customRange, stateCode, category } = options;
|
||||||
|
const { start, end } = getDateRangeFromWindow(window, customRange);
|
||||||
|
|
||||||
|
// Build filters
|
||||||
|
const params: any[] = [brandName, start, end];
|
||||||
|
let paramIdx = 4;
|
||||||
|
let filters = '';
|
||||||
|
|
||||||
|
if (stateCode) {
|
||||||
|
filters += ` AND s.code = $${paramIdx}`;
|
||||||
|
params.push(stateCode);
|
||||||
|
paramIdx++;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (category) {
|
||||||
|
filters += ` AND sp.category_raw = $${paramIdx}`;
|
||||||
|
params.push(category);
|
||||||
|
paramIdx++;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find promotional events by detecting when is_on_special transitions to TRUE
|
||||||
|
// and tracking until it transitions back to FALSE
|
||||||
|
const eventsResult = await this.pool.query(`
|
||||||
|
WITH snapshot_with_lag AS (
|
||||||
|
SELECT
|
||||||
|
sps.id,
|
||||||
|
sps.store_product_id,
|
||||||
|
sps.dispensary_id,
|
||||||
|
sps.brand_name_raw,
|
||||||
|
sps.name_raw,
|
||||||
|
sps.category_raw,
|
||||||
|
sps.is_on_special,
|
||||||
|
sps.price_rec,
|
||||||
|
sps.price_rec_special,
|
||||||
|
sps.stock_quantity,
|
||||||
|
sps.captured_at,
|
||||||
|
LAG(sps.is_on_special) OVER (
|
||||||
|
PARTITION BY sps.store_product_id
|
||||||
|
ORDER BY sps.captured_at
|
||||||
|
) AS prev_is_on_special,
|
||||||
|
LAG(sps.stock_quantity) OVER (
|
||||||
|
PARTITION BY sps.store_product_id
|
||||||
|
ORDER BY sps.captured_at
|
||||||
|
) AS prev_stock_quantity
|
||||||
|
FROM store_product_snapshots sps
|
||||||
|
JOIN store_products sp ON sp.id = sps.store_product_id
|
||||||
|
JOIN dispensaries dd ON dd.id = sp.dispensary_id
|
||||||
|
LEFT JOIN states s ON s.id = dd.state_id
|
||||||
|
WHERE sps.brand_name_raw = $1
|
||||||
|
AND sps.captured_at >= $2
|
||||||
|
AND sps.captured_at <= $3
|
||||||
|
${filters}
|
||||||
|
),
|
||||||
|
special_starts AS (
|
||||||
|
-- Find when specials START (transition from not-on-special to on-special)
|
||||||
|
SELECT
|
||||||
|
store_product_id,
|
||||||
|
dispensary_id,
|
||||||
|
name_raw,
|
||||||
|
category_raw,
|
||||||
|
captured_at AS special_start,
|
||||||
|
price_rec AS regular_price,
|
||||||
|
price_rec_special AS special_price,
|
||||||
|
stock_quantity AS quantity_at_start
|
||||||
|
FROM snapshot_with_lag
|
||||||
|
WHERE is_on_special = TRUE
|
||||||
|
AND (prev_is_on_special = FALSE OR prev_is_on_special IS NULL)
|
||||||
|
AND price_rec_special IS NOT NULL
|
||||||
|
AND price_rec IS NOT NULL
|
||||||
|
),
|
||||||
|
special_ends AS (
|
||||||
|
-- Find when specials END (transition from on-special to not-on-special)
|
||||||
|
SELECT
|
||||||
|
store_product_id,
|
||||||
|
captured_at AS special_end,
|
||||||
|
prev_stock_quantity AS quantity_at_end
|
||||||
|
FROM snapshot_with_lag
|
||||||
|
WHERE is_on_special = FALSE
|
||||||
|
AND prev_is_on_special = TRUE
|
||||||
|
),
|
||||||
|
matched_events AS (
|
||||||
|
SELECT
|
||||||
|
ss.store_product_id,
|
||||||
|
ss.dispensary_id,
|
||||||
|
ss.name_raw AS product_name,
|
||||||
|
ss.category_raw AS category,
|
||||||
|
ss.special_start,
|
||||||
|
se.special_end,
|
||||||
|
ss.regular_price,
|
||||||
|
ss.special_price,
|
||||||
|
ss.quantity_at_start,
|
||||||
|
COALESCE(se.quantity_at_end, ss.quantity_at_start) AS quantity_at_end
|
||||||
|
FROM special_starts ss
|
||||||
|
LEFT JOIN special_ends se ON se.store_product_id = ss.store_product_id
|
||||||
|
AND se.special_end > ss.special_start
|
||||||
|
AND se.special_end = (
|
||||||
|
SELECT MIN(se2.special_end)
|
||||||
|
FROM special_ends se2
|
||||||
|
WHERE se2.store_product_id = ss.store_product_id
|
||||||
|
AND se2.special_end > ss.special_start
|
||||||
|
)
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
me.store_product_id,
|
||||||
|
me.dispensary_id,
|
||||||
|
d.name AS dispensary_name,
|
||||||
|
s.code AS state_code,
|
||||||
|
me.product_name,
|
||||||
|
me.category,
|
||||||
|
me.special_start,
|
||||||
|
me.special_end,
|
||||||
|
EXTRACT(DAY FROM COALESCE(me.special_end, NOW()) - me.special_start)::INT AS duration_days,
|
||||||
|
me.regular_price,
|
||||||
|
me.special_price,
|
||||||
|
ROUND(((me.regular_price - me.special_price) / NULLIF(me.regular_price, 0)) * 100, 1) AS discount_percent,
|
||||||
|
me.quantity_at_start,
|
||||||
|
me.quantity_at_end,
|
||||||
|
GREATEST(0, COALESCE(me.quantity_at_start, 0) - COALESCE(me.quantity_at_end, 0)) AS quantity_sold_estimate
|
||||||
|
FROM matched_events me
|
||||||
|
JOIN dispensaries d ON d.id = me.dispensary_id
|
||||||
|
LEFT JOIN states s ON s.id = d.state_id
|
||||||
|
ORDER BY me.special_start DESC
|
||||||
|
`, params);
|
||||||
|
|
||||||
|
const events: BrandPromotionalEvent[] = eventsResult.rows.map((row: any) => ({
|
||||||
|
product_name: row.product_name,
|
||||||
|
store_product_id: parseInt(row.store_product_id),
|
||||||
|
dispensary_id: parseInt(row.dispensary_id),
|
||||||
|
dispensary_name: row.dispensary_name,
|
||||||
|
state_code: row.state_code || 'Unknown',
|
||||||
|
category: row.category,
|
||||||
|
special_start: row.special_start.toISOString().split('T')[0],
|
||||||
|
special_end: row.special_end ? row.special_end.toISOString().split('T')[0] : null,
|
||||||
|
duration_days: row.duration_days ? parseInt(row.duration_days) : null,
|
||||||
|
regular_price: parseFloat(row.regular_price) || 0,
|
||||||
|
special_price: parseFloat(row.special_price) || 0,
|
||||||
|
discount_percent: parseFloat(row.discount_percent) || 0,
|
||||||
|
quantity_at_start: row.quantity_at_start ? parseInt(row.quantity_at_start) : null,
|
||||||
|
quantity_at_end: row.quantity_at_end ? parseInt(row.quantity_at_end) : null,
|
||||||
|
quantity_sold_estimate: row.quantity_sold_estimate ? parseInt(row.quantity_sold_estimate) : null,
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Calculate summary stats
|
||||||
|
const totalEvents = events.length;
|
||||||
|
const uniqueProducts = new Set(events.map(e => e.store_product_id)).size;
|
||||||
|
const uniqueDispensaries = new Set(events.map(e => e.dispensary_id)).size;
|
||||||
|
const uniqueStates = [...new Set(events.map(e => e.state_code))];
|
||||||
|
|
||||||
|
const avgDiscount = totalEvents > 0
|
||||||
|
? events.reduce((sum, e) => sum + e.discount_percent, 0) / totalEvents
|
||||||
|
: 0;
|
||||||
|
|
||||||
|
const durations = events.filter(e => e.duration_days !== null).map(e => e.duration_days!);
|
||||||
|
const avgDuration = durations.length > 0
|
||||||
|
? durations.reduce((sum, d) => sum + d, 0) / durations.length
|
||||||
|
: null;
|
||||||
|
|
||||||
|
const totalQuantitySold = events
|
||||||
|
.filter(e => e.quantity_sold_estimate !== null)
|
||||||
|
.reduce((sum, e) => sum + (e.quantity_sold_estimate || 0), 0);
|
||||||
|
|
||||||
|
// Calculate frequency
|
||||||
|
const windowDays = Math.ceil((end.getTime() - start.getTime()) / (1000 * 60 * 60 * 24));
|
||||||
|
const weeklyAvg = windowDays > 0 ? (totalEvents / windowDays) * 7 : 0;
|
||||||
|
const monthlyAvg = windowDays > 0 ? (totalEvents / windowDays) * 30 : 0;
|
||||||
|
|
||||||
|
// Group by category
|
||||||
|
const categoryMap = new Map<string, { count: number; discounts: number[]; quantity: number }>();
|
||||||
|
for (const event of events) {
|
||||||
|
const cat = event.category || 'Uncategorized';
|
||||||
|
if (!categoryMap.has(cat)) {
|
||||||
|
categoryMap.set(cat, { count: 0, discounts: [], quantity: 0 });
|
||||||
|
}
|
||||||
|
const entry = categoryMap.get(cat)!;
|
||||||
|
entry.count++;
|
||||||
|
entry.discounts.push(event.discount_percent);
|
||||||
|
if (event.quantity_sold_estimate !== null) {
|
||||||
|
entry.quantity += event.quantity_sold_estimate;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const byCategory = Array.from(categoryMap.entries()).map(([category, data]) => ({
|
||||||
|
category,
|
||||||
|
event_count: data.count,
|
||||||
|
avg_discount_percent: data.discounts.length > 0
|
||||||
|
? Math.round((data.discounts.reduce((a, b) => a + b, 0) / data.discounts.length) * 10) / 10
|
||||||
|
: 0,
|
||||||
|
quantity_sold_estimate: data.quantity > 0 ? data.quantity : null,
|
||||||
|
})).sort((a, b) => b.event_count - a.event_count);
|
||||||
|
|
||||||
|
return {
|
||||||
|
brand_name: brandName,
|
||||||
|
window,
|
||||||
|
total_promotional_events: totalEvents,
|
||||||
|
total_products_on_special: uniqueProducts,
|
||||||
|
total_dispensaries_with_specials: uniqueDispensaries,
|
||||||
|
states_with_specials: uniqueStates,
|
||||||
|
avg_discount_percent: Math.round(avgDiscount * 10) / 10,
|
||||||
|
avg_duration_days: avgDuration !== null ? Math.round(avgDuration * 10) / 10 : null,
|
||||||
|
total_quantity_sold_estimate: totalQuantitySold > 0 ? totalQuantitySold : null,
|
||||||
|
promotional_frequency: {
|
||||||
|
weekly_avg: Math.round(weeklyAvg * 10) / 10,
|
||||||
|
monthly_avg: Math.round(monthlyAvg * 10) / 10,
|
||||||
|
},
|
||||||
|
by_category: byCategory,
|
||||||
|
events,
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export default BrandPenetrationService;
|
export default BrandPenetrationService;
|
||||||
|
|||||||
@@ -259,6 +259,122 @@ export class StoreAnalyticsService {
|
|||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get quantity changes for a store (increases/decreases)
|
||||||
|
* Useful for estimating sales (decreases) or restocks (increases)
|
||||||
|
*
|
||||||
|
* @param direction - 'decrease' for likely sales, 'increase' for restocks, 'all' for both
|
||||||
|
*/
|
||||||
|
async getQuantityChanges(
|
||||||
|
dispensaryId: number,
|
||||||
|
options: {
|
||||||
|
window?: TimeWindow;
|
||||||
|
customRange?: DateRange;
|
||||||
|
direction?: 'increase' | 'decrease' | 'all';
|
||||||
|
limit?: number;
|
||||||
|
} = {}
|
||||||
|
): Promise<{
|
||||||
|
dispensary_id: number;
|
||||||
|
window: TimeWindow;
|
||||||
|
direction: string;
|
||||||
|
total_changes: number;
|
||||||
|
total_units_decreased: number;
|
||||||
|
total_units_increased: number;
|
||||||
|
changes: Array<{
|
||||||
|
store_product_id: number;
|
||||||
|
product_name: string;
|
||||||
|
brand_name: string | null;
|
||||||
|
category: string | null;
|
||||||
|
old_quantity: number;
|
||||||
|
new_quantity: number;
|
||||||
|
quantity_delta: number;
|
||||||
|
direction: 'increase' | 'decrease';
|
||||||
|
captured_at: string;
|
||||||
|
}>;
|
||||||
|
}> {
|
||||||
|
const { window = '7d', customRange, direction = 'all', limit = 100 } = options;
|
||||||
|
const { start, end } = getDateRangeFromWindow(window, customRange);
|
||||||
|
|
||||||
|
// Build direction filter
|
||||||
|
let directionFilter = '';
|
||||||
|
if (direction === 'decrease') {
|
||||||
|
directionFilter = 'AND qty_delta < 0';
|
||||||
|
} else if (direction === 'increase') {
|
||||||
|
directionFilter = 'AND qty_delta > 0';
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await this.pool.query(`
|
||||||
|
WITH qty_changes AS (
|
||||||
|
SELECT
|
||||||
|
sps.store_product_id,
|
||||||
|
sp.name_raw AS product_name,
|
||||||
|
sp.brand_name_raw AS brand_name,
|
||||||
|
sp.category_raw AS category,
|
||||||
|
LAG(sps.stock_quantity) OVER w AS old_quantity,
|
||||||
|
sps.stock_quantity AS new_quantity,
|
||||||
|
sps.stock_quantity - LAG(sps.stock_quantity) OVER w AS qty_delta,
|
||||||
|
sps.captured_at
|
||||||
|
FROM store_product_snapshots sps
|
||||||
|
JOIN store_products sp ON sp.id = sps.store_product_id
|
||||||
|
WHERE sps.dispensary_id = $1
|
||||||
|
AND sps.captured_at >= $2
|
||||||
|
AND sps.captured_at <= $3
|
||||||
|
AND sps.stock_quantity IS NOT NULL
|
||||||
|
WINDOW w AS (PARTITION BY sps.store_product_id ORDER BY sps.captured_at)
|
||||||
|
)
|
||||||
|
SELECT *
|
||||||
|
FROM qty_changes
|
||||||
|
WHERE old_quantity IS NOT NULL
|
||||||
|
AND qty_delta != 0
|
||||||
|
${directionFilter}
|
||||||
|
ORDER BY captured_at DESC
|
||||||
|
LIMIT $4
|
||||||
|
`, [dispensaryId, start, end, limit]);
|
||||||
|
|
||||||
|
// Calculate totals
|
||||||
|
const totalsResult = await this.pool.query(`
|
||||||
|
WITH qty_changes AS (
|
||||||
|
SELECT
|
||||||
|
sps.stock_quantity - LAG(sps.stock_quantity) OVER w AS qty_delta
|
||||||
|
FROM store_product_snapshots sps
|
||||||
|
WHERE sps.dispensary_id = $1
|
||||||
|
AND sps.captured_at >= $2
|
||||||
|
AND sps.captured_at <= $3
|
||||||
|
AND sps.stock_quantity IS NOT NULL
|
||||||
|
AND sps.store_product_id IS NOT NULL
|
||||||
|
WINDOW w AS (PARTITION BY sps.store_product_id ORDER BY sps.captured_at)
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
COUNT(*) FILTER (WHERE qty_delta != 0) AS total_changes,
|
||||||
|
COALESCE(SUM(ABS(qty_delta)) FILTER (WHERE qty_delta < 0), 0) AS units_decreased,
|
||||||
|
COALESCE(SUM(qty_delta) FILTER (WHERE qty_delta > 0), 0) AS units_increased
|
||||||
|
FROM qty_changes
|
||||||
|
WHERE qty_delta IS NOT NULL
|
||||||
|
`, [dispensaryId, start, end]);
|
||||||
|
|
||||||
|
const totals = totalsResult.rows[0] || {};
|
||||||
|
|
||||||
|
return {
|
||||||
|
dispensary_id: dispensaryId,
|
||||||
|
window,
|
||||||
|
direction,
|
||||||
|
total_changes: parseInt(totals.total_changes) || 0,
|
||||||
|
total_units_decreased: parseInt(totals.units_decreased) || 0,
|
||||||
|
total_units_increased: parseInt(totals.units_increased) || 0,
|
||||||
|
changes: result.rows.map((row: any) => ({
|
||||||
|
store_product_id: row.store_product_id,
|
||||||
|
product_name: row.product_name,
|
||||||
|
brand_name: row.brand_name,
|
||||||
|
category: row.category,
|
||||||
|
old_quantity: row.old_quantity,
|
||||||
|
new_quantity: row.new_quantity,
|
||||||
|
quantity_delta: row.qty_delta,
|
||||||
|
direction: row.qty_delta > 0 ? 'increase' : 'decrease',
|
||||||
|
captured_at: row.captured_at?.toISOString() || null,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get store inventory composition (categories and brands breakdown)
|
* Get store inventory composition (categories and brands breakdown)
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -322,3 +322,48 @@ export interface RecVsMedPriceComparison {
|
|||||||
};
|
};
|
||||||
price_diff_percent: number | null;
|
price_diff_percent: number | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// BRAND PROMOTIONAL ANALYTICS TYPES
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
export interface BrandPromotionalEvent {
|
||||||
|
product_name: string;
|
||||||
|
store_product_id: number;
|
||||||
|
dispensary_id: number;
|
||||||
|
dispensary_name: string;
|
||||||
|
state_code: string;
|
||||||
|
category: string | null;
|
||||||
|
special_start: string; // ISO date when special started
|
||||||
|
special_end: string | null; // ISO date when special ended (null if ongoing)
|
||||||
|
duration_days: number | null;
|
||||||
|
regular_price: number;
|
||||||
|
special_price: number;
|
||||||
|
discount_percent: number;
|
||||||
|
quantity_at_start: number | null;
|
||||||
|
quantity_at_end: number | null;
|
||||||
|
quantity_sold_estimate: number | null; // quantity_at_start - quantity_at_end
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BrandPromotionalSummary {
|
||||||
|
brand_name: string;
|
||||||
|
window: TimeWindow;
|
||||||
|
total_promotional_events: number;
|
||||||
|
total_products_on_special: number;
|
||||||
|
total_dispensaries_with_specials: number;
|
||||||
|
states_with_specials: string[];
|
||||||
|
avg_discount_percent: number;
|
||||||
|
avg_duration_days: number | null;
|
||||||
|
total_quantity_sold_estimate: number | null;
|
||||||
|
promotional_frequency: {
|
||||||
|
weekly_avg: number;
|
||||||
|
monthly_avg: number;
|
||||||
|
};
|
||||||
|
by_category: Array<{
|
||||||
|
category: string;
|
||||||
|
event_count: number;
|
||||||
|
avg_discount_percent: number;
|
||||||
|
quantity_sold_estimate: number | null;
|
||||||
|
}>;
|
||||||
|
events: BrandPromotionalEvent[];
|
||||||
|
}
|
||||||
|
|||||||
@@ -39,7 +39,12 @@ export async function cleanupOrphanedJobs(): Promise<void> {
|
|||||||
|
|
||||||
export type ProxyTestMode = 'all' | 'failed' | 'inactive';
|
export type ProxyTestMode = 'all' | 'failed' | 'inactive';
|
||||||
|
|
||||||
export async function createProxyTestJob(mode: ProxyTestMode = 'all', concurrency: number = DEFAULT_CONCURRENCY): Promise<number> {
|
export interface CreateJobResult {
|
||||||
|
jobId: number;
|
||||||
|
totalProxies: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createProxyTestJob(mode: ProxyTestMode = 'all', concurrency: number = DEFAULT_CONCURRENCY): Promise<CreateJobResult> {
|
||||||
// Check for existing running jobs first
|
// Check for existing running jobs first
|
||||||
const existingJob = await getActiveProxyTestJob();
|
const existingJob = await getActiveProxyTestJob();
|
||||||
if (existingJob) {
|
if (existingJob) {
|
||||||
@@ -79,7 +84,7 @@ export async function createProxyTestJob(mode: ProxyTestMode = 'all', concurrenc
|
|||||||
console.error(`❌ Proxy test job ${jobId} failed:`, err);
|
console.error(`❌ Proxy test job ${jobId} failed:`, err);
|
||||||
});
|
});
|
||||||
|
|
||||||
return jobId;
|
return { jobId, totalProxies };
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getProxyTestJob(jobId: number): Promise<ProxyTestJob | null> {
|
export async function getProxyTestJob(jobId: number): Promise<ProxyTestJob | null> {
|
||||||
|
|||||||
@@ -10,6 +10,17 @@
|
|||||||
|
|
||||||
import { pool } from '../db/pool';
|
import { pool } from '../db/pool';
|
||||||
|
|
||||||
|
// Helper to check if a table exists
|
||||||
|
async function tableExists(tableName: string): Promise<boolean> {
|
||||||
|
const result = await pool.query(`
|
||||||
|
SELECT EXISTS (
|
||||||
|
SELECT FROM information_schema.tables
|
||||||
|
WHERE table_name = $1
|
||||||
|
) as exists
|
||||||
|
`, [tableName]);
|
||||||
|
return result.rows[0].exists;
|
||||||
|
}
|
||||||
|
|
||||||
export type TaskRole =
|
export type TaskRole =
|
||||||
| 'store_discovery'
|
| 'store_discovery'
|
||||||
| 'entry_point_discovery'
|
| 'entry_point_discovery'
|
||||||
@@ -270,6 +281,11 @@ class TaskService {
|
|||||||
* List tasks with filters
|
* List tasks with filters
|
||||||
*/
|
*/
|
||||||
async listTasks(filter: TaskFilter = {}): Promise<WorkerTask[]> {
|
async listTasks(filter: TaskFilter = {}): Promise<WorkerTask[]> {
|
||||||
|
// Return empty list if table doesn't exist
|
||||||
|
if (!await tableExists('worker_tasks')) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
const conditions: string[] = [];
|
const conditions: string[] = [];
|
||||||
const params: (string | number | string[])[] = [];
|
const params: (string | number | string[])[] = [];
|
||||||
let paramIndex = 1;
|
let paramIndex = 1;
|
||||||
@@ -323,21 +339,41 @@ class TaskService {
|
|||||||
* Get capacity metrics for all roles
|
* Get capacity metrics for all roles
|
||||||
*/
|
*/
|
||||||
async getCapacityMetrics(): Promise<CapacityMetrics[]> {
|
async getCapacityMetrics(): Promise<CapacityMetrics[]> {
|
||||||
|
// Return empty metrics if worker_tasks table doesn't exist
|
||||||
|
if (!await tableExists('worker_tasks')) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
const result = await pool.query(
|
const result = await pool.query(
|
||||||
`SELECT * FROM v_worker_capacity`
|
`SELECT * FROM v_worker_capacity`
|
||||||
);
|
);
|
||||||
return result.rows as CapacityMetrics[];
|
return result.rows as CapacityMetrics[];
|
||||||
|
} catch {
|
||||||
|
// View may not exist
|
||||||
|
return [];
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get capacity metrics for a specific role
|
* Get capacity metrics for a specific role
|
||||||
*/
|
*/
|
||||||
async getRoleCapacity(role: TaskRole): Promise<CapacityMetrics | null> {
|
async getRoleCapacity(role: TaskRole): Promise<CapacityMetrics | null> {
|
||||||
|
// Return null if worker_tasks table doesn't exist
|
||||||
|
if (!await tableExists('worker_tasks')) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
const result = await pool.query(
|
const result = await pool.query(
|
||||||
`SELECT * FROM v_worker_capacity WHERE role = $1`,
|
`SELECT * FROM v_worker_capacity WHERE role = $1`,
|
||||||
[role]
|
[role]
|
||||||
);
|
);
|
||||||
return (result.rows[0] as CapacityMetrics) || null;
|
return (result.rows[0] as CapacityMetrics) || null;
|
||||||
|
} catch {
|
||||||
|
// View may not exist
|
||||||
|
return null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -463,12 +499,6 @@ class TaskService {
|
|||||||
* Get task counts by status for dashboard
|
* Get task counts by status for dashboard
|
||||||
*/
|
*/
|
||||||
async getTaskCounts(): Promise<Record<TaskStatus, number>> {
|
async getTaskCounts(): Promise<Record<TaskStatus, number>> {
|
||||||
const result = await pool.query(
|
|
||||||
`SELECT status, COUNT(*) as count
|
|
||||||
FROM worker_tasks
|
|
||||||
GROUP BY status`
|
|
||||||
);
|
|
||||||
|
|
||||||
const counts: Record<TaskStatus, number> = {
|
const counts: Record<TaskStatus, number> = {
|
||||||
pending: 0,
|
pending: 0,
|
||||||
claimed: 0,
|
claimed: 0,
|
||||||
@@ -478,6 +508,17 @@ class TaskService {
|
|||||||
stale: 0,
|
stale: 0,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Return empty counts if table doesn't exist
|
||||||
|
if (!await tableExists('worker_tasks')) {
|
||||||
|
return counts;
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await pool.query(
|
||||||
|
`SELECT status, COUNT(*) as count
|
||||||
|
FROM worker_tasks
|
||||||
|
GROUP BY status`
|
||||||
|
);
|
||||||
|
|
||||||
for (const row of result.rows) {
|
for (const row of result.rows) {
|
||||||
const typedRow = row as { status: TaskStatus; count: string };
|
const typedRow = row as { status: TaskStatus; count: string };
|
||||||
counts[typedRow.status] = parseInt(typedRow.count, 10);
|
counts[typedRow.status] = parseInt(typedRow.count, 10);
|
||||||
|
|||||||
@@ -6,8 +6,8 @@ WORKDIR /app
|
|||||||
# Copy package files
|
# Copy package files
|
||||||
COPY package*.json ./
|
COPY package*.json ./
|
||||||
|
|
||||||
# Install dependencies
|
# Install dependencies (npm install is more forgiving than npm ci)
|
||||||
RUN npm ci
|
RUN npm install
|
||||||
|
|
||||||
# Copy source files
|
# Copy source files
|
||||||
COPY . .
|
COPY . .
|
||||||
|
|||||||
@@ -320,7 +320,7 @@ class ApiClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async testAllProxies() {
|
async testAllProxies() {
|
||||||
return this.request<{ jobId: number; message: string }>('/api/proxies/test-all', {
|
return this.request<{ jobId: number; total: number; message: string }>('/api/proxies/test-all', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { useEffect, useState, useRef } from 'react';
|
|||||||
import { Layout } from '../components/Layout';
|
import { Layout } from '../components/Layout';
|
||||||
import { api } from '../lib/api';
|
import { api } from '../lib/api';
|
||||||
import { Toast } from '../components/Toast';
|
import { Toast } from '../components/Toast';
|
||||||
import { Key, Plus, Copy, Check, X, Trash2, Power, PowerOff, Store, Globe, Shield, Clock, Eye, EyeOff, Search, ChevronDown } from 'lucide-react';
|
import { Key, Plus, Copy, Check, X, Trash2, Power, PowerOff, Store, Globe, Shield, Clock, Eye, EyeOff, Search, ChevronDown, Pencil } from 'lucide-react';
|
||||||
|
|
||||||
interface ApiPermission {
|
interface ApiPermission {
|
||||||
id: number;
|
id: number;
|
||||||
@@ -161,6 +161,12 @@ export function ApiPermissions() {
|
|||||||
allowed_ips: '',
|
allowed_ips: '',
|
||||||
allowed_domains: '',
|
allowed_domains: '',
|
||||||
});
|
});
|
||||||
|
const [editingPermission, setEditingPermission] = useState<ApiPermission | null>(null);
|
||||||
|
const [editForm, setEditForm] = useState({
|
||||||
|
user_name: '',
|
||||||
|
allowed_ips: '',
|
||||||
|
allowed_domains: '',
|
||||||
|
});
|
||||||
const [notification, setNotification] = useState<{ message: string; type: 'success' | 'error' | 'info' } | null>(null);
|
const [notification, setNotification] = useState<{ message: string; type: 'success' | 'error' | 'info' } | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -240,6 +246,33 @@ export function ApiPermissions() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleEdit = (perm: ApiPermission) => {
|
||||||
|
setEditingPermission(perm);
|
||||||
|
setEditForm({
|
||||||
|
user_name: perm.user_name,
|
||||||
|
allowed_ips: perm.allowed_ips || '',
|
||||||
|
allowed_domains: perm.allowed_domains || '',
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSaveEdit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!editingPermission) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await api.updateApiPermission(editingPermission.id, {
|
||||||
|
user_name: editForm.user_name,
|
||||||
|
allowed_ips: editForm.allowed_ips || undefined,
|
||||||
|
allowed_domains: editForm.allowed_domains || undefined,
|
||||||
|
});
|
||||||
|
setNotification({ message: 'API key updated successfully', type: 'success' });
|
||||||
|
setEditingPermission(null);
|
||||||
|
loadPermissions();
|
||||||
|
} catch (error: any) {
|
||||||
|
setNotification({ message: 'Failed to update permission: ' + error.message, type: 'error' });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const copyToClipboard = async (text: string, id: number) => {
|
const copyToClipboard = async (text: string, id: number) => {
|
||||||
await navigator.clipboard.writeText(text);
|
await navigator.clipboard.writeText(text);
|
||||||
setCopiedId(id);
|
setCopiedId(id);
|
||||||
@@ -494,21 +527,36 @@ export function ApiPermissions() {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Restrictions */}
|
{/* Allowed Domains - Always show */}
|
||||||
{(perm.allowed_ips || perm.allowed_domains) && (
|
<div className="mt-3 text-xs">
|
||||||
<div className="flex gap-4 mt-3 text-xs text-gray-500">
|
<span className="text-gray-500 flex items-center gap-1">
|
||||||
{perm.allowed_ips && (
|
<Globe className="w-3 h-3" />
|
||||||
<span>IPs: {perm.allowed_ips.split('\n').length} allowed</span>
|
Domains:{' '}
|
||||||
|
{perm.allowed_domains ? (
|
||||||
|
<span className="text-gray-700 font-mono">
|
||||||
|
{perm.allowed_domains.split('\n').filter(d => d.trim()).join(', ')}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="text-amber-600">Any domain (no restriction)</span>
|
||||||
)}
|
)}
|
||||||
{perm.allowed_domains && (
|
</span>
|
||||||
<span>Domains: {perm.allowed_domains.split('\n').length} allowed</span>
|
{perm.allowed_ips && (
|
||||||
|
<span className="text-gray-500 ml-4">
|
||||||
|
IPs: {perm.allowed_ips.split('\n').filter(ip => ip.trim()).length} allowed
|
||||||
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Actions */}
|
{/* Actions */}
|
||||||
<div className="flex items-center gap-2 ml-4">
|
<div className="flex items-center gap-2 ml-4">
|
||||||
|
<button
|
||||||
|
onClick={() => handleEdit(perm)}
|
||||||
|
className="p-2 text-blue-600 hover:bg-blue-50 rounded-lg transition-colors"
|
||||||
|
title="Edit"
|
||||||
|
>
|
||||||
|
<Pencil className="w-5 h-5" />
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => handleToggle(perm.id)}
|
onClick={() => handleToggle(perm.id)}
|
||||||
className={`p-2 rounded-lg transition-colors ${
|
className={`p-2 rounded-lg transition-colors ${
|
||||||
@@ -534,6 +582,86 @@ export function ApiPermissions() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Edit Modal */}
|
||||||
|
{editingPermission && (
|
||||||
|
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
|
||||||
|
<div className="bg-white rounded-xl shadow-xl max-w-lg w-full mx-4 max-h-[90vh] overflow-y-auto">
|
||||||
|
<div className="px-6 py-4 border-b border-gray-200">
|
||||||
|
<h2 className="text-lg font-semibold text-gray-900 flex items-center gap-2">
|
||||||
|
<Pencil className="w-5 h-5 text-blue-600" />
|
||||||
|
Edit API Key
|
||||||
|
</h2>
|
||||||
|
<p className="text-sm text-gray-500 mt-1">
|
||||||
|
{editingPermission.store_name}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form onSubmit={handleSaveEdit} className="p-6 space-y-5">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||||
|
Label / Website Name
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={editForm.user_name}
|
||||||
|
onChange={(e) => setEditForm({ ...editForm, user_name: e.target.value })}
|
||||||
|
className="w-full px-4 py-2.5 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||||
|
<Globe className="w-4 h-4 inline mr-1" />
|
||||||
|
Allowed Domains
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
value={editForm.allowed_domains}
|
||||||
|
onChange={(e) => setEditForm({ ...editForm, allowed_domains: e.target.value })}
|
||||||
|
rows={4}
|
||||||
|
className="w-full px-4 py-2.5 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent font-mono text-sm"
|
||||||
|
placeholder="example.com *.example.com subdomain.example.com"
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-gray-500 mt-1">
|
||||||
|
One domain per line. Use * for wildcards (e.g., *.example.com). Leave empty to allow any domain.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||||
|
<Shield className="w-4 h-4 inline mr-1" />
|
||||||
|
Allowed IP Addresses
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
value={editForm.allowed_ips}
|
||||||
|
onChange={(e) => setEditForm({ ...editForm, allowed_ips: e.target.value })}
|
||||||
|
rows={3}
|
||||||
|
className="w-full px-4 py-2.5 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent font-mono text-sm"
|
||||||
|
placeholder="192.168.1.1 10.0.0.0/8"
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-gray-500 mt-1">One per line. CIDR notation supported. Leave empty to allow any IP.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-3 pt-2">
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="flex-1 px-5 py-2.5 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors"
|
||||||
|
>
|
||||||
|
Save Changes
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setEditingPermission(null)}
|
||||||
|
className="px-5 py-2.5 bg-gray-100 text-gray-700 rounded-lg hover:bg-gray-200 transition-colors"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</Layout>
|
</Layout>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -96,7 +96,8 @@ export function Proxies() {
|
|||||||
try {
|
try {
|
||||||
const response = await api.testAllProxies();
|
const response = await api.testAllProxies();
|
||||||
setNotification({ message: 'Proxy testing job started', type: 'success' });
|
setNotification({ message: 'Proxy testing job started', type: 'success' });
|
||||||
setActiveJob({ id: response.jobId, status: 'pending', tested_proxies: 0, total_proxies: proxies.length, passed_proxies: 0, failed_proxies: 0 });
|
// Use response.total if available, otherwise proxies.length, but immediately poll for accurate count
|
||||||
|
setActiveJob({ id: response.jobId, status: 'pending', tested_proxies: 0, total_proxies: response.total || proxies.length || 0, passed_proxies: 0, failed_proxies: 0 });
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
setNotification({ message: 'Failed to start testing: ' + error.message, type: 'error' });
|
setNotification({ message: 'Failed to start testing: ' + error.message, type: 'error' });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
|
|
||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { api } from '../../../lib/api';
|
import { api } from '../../../lib/api';
|
||||||
import { Building2, Tag, Globe, Target, FileText, RefreshCw, Sparkles, Loader2 } from 'lucide-react';
|
import { Building2, Tag, Globe, Target, FileText, RefreshCw, Sparkles, Loader2, AlertCircle } from 'lucide-react';
|
||||||
|
|
||||||
interface SeoPage {
|
interface SeoPage {
|
||||||
id: number;
|
id: number;
|
||||||
@@ -47,11 +47,31 @@ export function PagesTab() {
|
|||||||
const [search, setSearch] = useState('');
|
const [search, setSearch] = useState('');
|
||||||
const [syncing, setSyncing] = useState(false);
|
const [syncing, setSyncing] = useState(false);
|
||||||
const [generatingId, setGeneratingId] = useState<number | null>(null);
|
const [generatingId, setGeneratingId] = useState<number | null>(null);
|
||||||
|
const [hasActiveAiProvider, setHasActiveAiProvider] = useState<boolean | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadPages();
|
loadPages();
|
||||||
|
checkAiProvider();
|
||||||
}, [typeFilter, search]);
|
}, [typeFilter, search]);
|
||||||
|
|
||||||
|
async function checkAiProvider() {
|
||||||
|
try {
|
||||||
|
const data = await api.getSettings();
|
||||||
|
const settings = data.settings || [];
|
||||||
|
// Check if either Anthropic or OpenAI is configured with an API key AND enabled
|
||||||
|
const anthropicKey = settings.find((s: any) => s.key === 'anthropic_api_key')?.value;
|
||||||
|
const anthropicEnabled = settings.find((s: any) => s.key === 'anthropic_enabled')?.value === 'true';
|
||||||
|
const openaiKey = settings.find((s: any) => s.key === 'openai_api_key')?.value;
|
||||||
|
const openaiEnabled = settings.find((s: any) => s.key === 'openai_enabled')?.value === 'true';
|
||||||
|
|
||||||
|
const hasProvider = (anthropicKey && anthropicEnabled) || (openaiKey && openaiEnabled);
|
||||||
|
setHasActiveAiProvider(!!hasProvider);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to check AI provider:', error);
|
||||||
|
setHasActiveAiProvider(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function loadPages() {
|
async function loadPages() {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
@@ -188,12 +208,18 @@ export function PagesTab() {
|
|||||||
<td className="px-3 sm:px-4 py-3">
|
<td className="px-3 sm:px-4 py-3">
|
||||||
<button
|
<button
|
||||||
onClick={() => handleGenerate(page.id)}
|
onClick={() => handleGenerate(page.id)}
|
||||||
disabled={generatingId === page.id}
|
disabled={generatingId === page.id || hasActiveAiProvider === false}
|
||||||
className="flex items-center gap-1 px-2 sm:px-3 py-1.5 text-xs font-medium bg-purple-50 text-purple-700 rounded-lg hover:bg-purple-100 disabled:opacity-50"
|
className={`flex items-center gap-1 px-2 sm:px-3 py-1.5 text-xs font-medium rounded-lg disabled:cursor-not-allowed ${
|
||||||
title="Generate content"
|
hasActiveAiProvider === false
|
||||||
|
? 'bg-gray-100 text-gray-400'
|
||||||
|
: 'bg-purple-50 text-purple-700 hover:bg-purple-100 disabled:opacity-50'
|
||||||
|
}`}
|
||||||
|
title={hasActiveAiProvider === false ? 'No Active AI Provider' : 'Generate content'}
|
||||||
>
|
>
|
||||||
{generatingId === page.id ? (
|
{generatingId === page.id ? (
|
||||||
<Loader2 className="w-3.5 h-3.5 animate-spin" />
|
<Loader2 className="w-3.5 h-3.5 animate-spin" />
|
||||||
|
) : hasActiveAiProvider === false ? (
|
||||||
|
<AlertCircle className="w-3.5 h-3.5" />
|
||||||
) : (
|
) : (
|
||||||
<Sparkles className="w-3.5 h-3.5" />
|
<Sparkles className="w-3.5 h-3.5" />
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -7,16 +7,6 @@
|
|||||||
"src": "favicon.ico",
|
"src": "favicon.ico",
|
||||||
"sizes": "64x64 32x32 24x24 16x16",
|
"sizes": "64x64 32x32 24x24 16x16",
|
||||||
"type": "image/x-icon"
|
"type": "image/x-icon"
|
||||||
},
|
|
||||||
{
|
|
||||||
"src": "logo192.png",
|
|
||||||
"type": "image/png",
|
|
||||||
"sizes": "192x192"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"src": "logo512.png",
|
|
||||||
"type": "image/png",
|
|
||||||
"sizes": "512x512"
|
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"start_url": ".",
|
"start_url": ".",
|
||||||
|
|||||||
@@ -373,10 +373,12 @@ export function mapCategoryForUI(apiCategory) {
|
|||||||
* Map API brand to UI-compatible format
|
* Map API brand to UI-compatible format
|
||||||
*/
|
*/
|
||||||
export function mapBrandForUI(apiBrand) {
|
export function mapBrandForUI(apiBrand) {
|
||||||
|
// API returns 'brand' field (see /api/v1/brands endpoint)
|
||||||
|
const brandName = apiBrand.brand || apiBrand.brand_name || '';
|
||||||
return {
|
return {
|
||||||
id: apiBrand.brand_name,
|
id: brandName,
|
||||||
name: apiBrand.brand_name,
|
name: brandName,
|
||||||
slug: apiBrand.brand_name?.toLowerCase().replace(/\s+/g, '-'),
|
slug: brandName ? brandName.toLowerCase().replace(/\s+/g, '-') : '',
|
||||||
logo: apiBrand.brand_logo_url || null,
|
logo: apiBrand.brand_logo_url || null,
|
||||||
productCount: parseInt(apiBrand.product_count || 0, 10),
|
productCount: parseInt(apiBrand.product_count || 0, 10),
|
||||||
dispensaryCount: parseInt(apiBrand.dispensary_count || 0, 10),
|
dispensaryCount: parseInt(apiBrand.dispensary_count || 0, 10),
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ const Brands = () => {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const filteredBrands = brands.filter((brand) =>
|
const filteredBrands = brands.filter((brand) =>
|
||||||
brand.name.toLowerCase().includes(searchQuery.toLowerCase())
|
brand.name && brand.name.toLowerCase().includes(searchQuery.toLowerCase())
|
||||||
);
|
);
|
||||||
|
|
||||||
// Group brands alphabetically
|
// Group brands alphabetically
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
1.5.4
|
1.6.0
|
||||||
|
|||||||
@@ -312,3 +312,184 @@
|
|||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
border-left: 4px solid #c62828;
|
border-left: 4px solid #c62828;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ========================================
|
||||||
|
Brand Grid Widget
|
||||||
|
======================================== */
|
||||||
|
.cannaiq-brand-grid {
|
||||||
|
display: grid;
|
||||||
|
gap: 20px;
|
||||||
|
margin: 20px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cannaiq-brand-card {
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 20px;
|
||||||
|
text-align: center;
|
||||||
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||||
|
transition: transform 0.2s, box-shadow 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cannaiq-brand-card:hover {
|
||||||
|
transform: translateY(-4px);
|
||||||
|
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cannaiq-brand-name {
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 600;
|
||||||
|
margin: 0 0 8px 0;
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cannaiq-brand-count {
|
||||||
|
font-size: 13px;
|
||||||
|
color: #666;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ========================================
|
||||||
|
Category List Widget
|
||||||
|
======================================== */
|
||||||
|
.cannaiq-category-grid {
|
||||||
|
display: grid;
|
||||||
|
gap: 16px;
|
||||||
|
margin: 20px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cannaiq-category-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
margin: 20px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cannaiq-category-pills {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 10px;
|
||||||
|
margin: 20px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cannaiq-category-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 12px 16px;
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 8px;
|
||||||
|
text-decoration: none;
|
||||||
|
color: #333;
|
||||||
|
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.08);
|
||||||
|
transition: background 0.2s, transform 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cannaiq-category-item:hover {
|
||||||
|
background: #f3f4f6;
|
||||||
|
transform: translateX(4px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cannaiq-category-pills-item {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 8px 16px;
|
||||||
|
background: #f3f4f6;
|
||||||
|
border-radius: 20px;
|
||||||
|
text-decoration: none;
|
||||||
|
color: #333;
|
||||||
|
font-size: 14px;
|
||||||
|
transition: background 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cannaiq-category-pills-item:hover {
|
||||||
|
background: #e5e7eb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cannaiq-category-name {
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cannaiq-category-count {
|
||||||
|
font-size: 13px;
|
||||||
|
color: #666;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ========================================
|
||||||
|
Specials/Deals Grid Widget
|
||||||
|
======================================== */
|
||||||
|
.cannaiq-specials-grid {
|
||||||
|
display: grid;
|
||||||
|
gap: 24px;
|
||||||
|
margin: 20px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cannaiq-special-card {
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 8px;
|
||||||
|
overflow: hidden;
|
||||||
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||||
|
transition: transform 0.2s, box-shadow 0.2s;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cannaiq-special-card:hover {
|
||||||
|
transform: translateY(-4px);
|
||||||
|
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cannaiq-discount-badge {
|
||||||
|
position: absolute;
|
||||||
|
top: 12px;
|
||||||
|
right: 12px;
|
||||||
|
background: #ef4444;
|
||||||
|
color: #fff;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 700;
|
||||||
|
padding: 4px 10px;
|
||||||
|
border-radius: 4px;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cannaiq-special-image {
|
||||||
|
width: 100%;
|
||||||
|
aspect-ratio: 1;
|
||||||
|
overflow: hidden;
|
||||||
|
background: #f5f5f5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cannaiq-special-image img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cannaiq-special-content {
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cannaiq-special-title {
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 600;
|
||||||
|
margin: 0 0 8px 0;
|
||||||
|
color: #333;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cannaiq-special-price {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
margin-top: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cannaiq-special-price .cannaiq-price-sale {
|
||||||
|
font-size: 20px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #16a34a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cannaiq-special-price .cannaiq-price-regular {
|
||||||
|
font-size: 14px;
|
||||||
|
color: #999;
|
||||||
|
}
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
* Plugin Name: CannaIQ Menus
|
* Plugin Name: CannaIQ Menus
|
||||||
* Plugin URI: https://cannaiq.co
|
* Plugin URI: https://cannaiq.co
|
||||||
* Description: Display cannabis product menus from CannaIQ with Elementor integration. Real-time menu data updated daily.
|
* Description: Display cannabis product menus from CannaIQ with Elementor integration. Real-time menu data updated daily.
|
||||||
* Version: 1.5.4
|
* Version: 1.6.0
|
||||||
* Author: CannaIQ
|
* Author: CannaIQ
|
||||||
* Author URI: https://cannaiq.co
|
* Author URI: https://cannaiq.co
|
||||||
* License: GPL v2 or later
|
* License: GPL v2 or later
|
||||||
@@ -15,7 +15,7 @@ if (!defined('ABSPATH')) {
|
|||||||
exit; // Exit if accessed directly
|
exit; // Exit if accessed directly
|
||||||
}
|
}
|
||||||
|
|
||||||
define('CANNAIQ_MENUS_VERSION', '1.5.4');
|
define('CANNAIQ_MENUS_VERSION', '1.6.0');
|
||||||
define('CANNAIQ_MENUS_API_URL', 'https://cannaiq.co/api/v1');
|
define('CANNAIQ_MENUS_API_URL', 'https://cannaiq.co/api/v1');
|
||||||
define('CANNAIQ_MENUS_PLUGIN_DIR', plugin_dir_path(__FILE__));
|
define('CANNAIQ_MENUS_PLUGIN_DIR', plugin_dir_path(__FILE__));
|
||||||
define('CANNAIQ_MENUS_PLUGIN_URL', plugin_dir_url(__FILE__));
|
define('CANNAIQ_MENUS_PLUGIN_URL', plugin_dir_url(__FILE__));
|
||||||
@@ -62,9 +62,15 @@ class CannaIQ_Menus_Plugin {
|
|||||||
public function register_elementor_widgets($widgets_manager) {
|
public function register_elementor_widgets($widgets_manager) {
|
||||||
require_once CANNAIQ_MENUS_PLUGIN_DIR . 'widgets/product-grid.php';
|
require_once CANNAIQ_MENUS_PLUGIN_DIR . 'widgets/product-grid.php';
|
||||||
require_once CANNAIQ_MENUS_PLUGIN_DIR . 'widgets/single-product.php';
|
require_once CANNAIQ_MENUS_PLUGIN_DIR . 'widgets/single-product.php';
|
||||||
|
require_once CANNAIQ_MENUS_PLUGIN_DIR . 'widgets/brand-grid.php';
|
||||||
|
require_once CANNAIQ_MENUS_PLUGIN_DIR . 'widgets/category-list.php';
|
||||||
|
require_once CANNAIQ_MENUS_PLUGIN_DIR . 'widgets/specials-grid.php';
|
||||||
|
|
||||||
$widgets_manager->register(new \CannaIQ_Menus_Product_Grid_Widget());
|
$widgets_manager->register(new \CannaIQ_Menus_Product_Grid_Widget());
|
||||||
$widgets_manager->register(new \CannaIQ_Menus_Single_Product_Widget());
|
$widgets_manager->register(new \CannaIQ_Menus_Single_Product_Widget());
|
||||||
|
$widgets_manager->register(new \CannaIQ_Menus_Brand_Grid_Widget());
|
||||||
|
$widgets_manager->register(new \CannaIQ_Menus_Category_List_Widget());
|
||||||
|
$widgets_manager->register(new \CannaIQ_Menus_Specials_Grid_Widget());
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -392,6 +398,152 @@ class CannaIQ_Menus_Plugin {
|
|||||||
|
|
||||||
return $data['product'] ?? false;
|
return $data['product'] ?? false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch Categories from API
|
||||||
|
*/
|
||||||
|
public function fetch_categories($args = []) {
|
||||||
|
$api_token = get_option('cannaiq_api_token');
|
||||||
|
|
||||||
|
if (!$api_token) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$query_args = http_build_query($args);
|
||||||
|
$url = CANNAIQ_MENUS_API_URL . '/categories' . ($query_args ? '?' . $query_args : '');
|
||||||
|
|
||||||
|
$response = wp_remote_get($url, [
|
||||||
|
'headers' => [
|
||||||
|
'X-API-Key' => $api_token
|
||||||
|
],
|
||||||
|
'timeout' => 30
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (is_wp_error($response)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$body = wp_remote_retrieve_body($response);
|
||||||
|
$data = json_decode($body, true);
|
||||||
|
|
||||||
|
return $data['categories'] ?? false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch Brands from API
|
||||||
|
*/
|
||||||
|
public function fetch_brands($args = []) {
|
||||||
|
$api_token = get_option('cannaiq_api_token');
|
||||||
|
|
||||||
|
if (!$api_token) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$query_args = http_build_query($args);
|
||||||
|
$url = CANNAIQ_MENUS_API_URL . '/brands' . ($query_args ? '?' . $query_args : '');
|
||||||
|
|
||||||
|
$response = wp_remote_get($url, [
|
||||||
|
'headers' => [
|
||||||
|
'X-API-Key' => $api_token
|
||||||
|
],
|
||||||
|
'timeout' => 30
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (is_wp_error($response)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$body = wp_remote_retrieve_body($response);
|
||||||
|
$data = json_decode($body, true);
|
||||||
|
|
||||||
|
return $data['brands'] ?? false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch Specials/Deals from API
|
||||||
|
*/
|
||||||
|
public function fetch_specials($args = []) {
|
||||||
|
$api_token = get_option('cannaiq_api_token');
|
||||||
|
|
||||||
|
if (!$api_token) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$query_args = http_build_query($args);
|
||||||
|
$url = CANNAIQ_MENUS_API_URL . '/specials' . ($query_args ? '?' . $query_args : '');
|
||||||
|
|
||||||
|
$response = wp_remote_get($url, [
|
||||||
|
'headers' => [
|
||||||
|
'X-API-Key' => $api_token
|
||||||
|
],
|
||||||
|
'timeout' => 30
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (is_wp_error($response)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$body = wp_remote_retrieve_body($response);
|
||||||
|
$data = json_decode($body, true);
|
||||||
|
|
||||||
|
return $data['products'] ?? false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get categories as options for Elementor select control
|
||||||
|
* Returns cached results for performance
|
||||||
|
*/
|
||||||
|
public function get_category_options() {
|
||||||
|
$cache_key = 'cannaiq_category_options';
|
||||||
|
$cached = get_transient($cache_key);
|
||||||
|
|
||||||
|
if ($cached !== false) {
|
||||||
|
return $cached;
|
||||||
|
}
|
||||||
|
|
||||||
|
$categories = $this->fetch_categories();
|
||||||
|
$options = ['' => __('All Categories', 'cannaiq-menus')];
|
||||||
|
|
||||||
|
if ($categories) {
|
||||||
|
foreach ($categories as $cat) {
|
||||||
|
$name = $cat['type'] ?? $cat['name'] ?? '';
|
||||||
|
if ($name) {
|
||||||
|
$options[$name] = ucwords(str_replace('_', ' ', $name));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
set_transient($cache_key, $options, 5 * MINUTE_IN_SECONDS);
|
||||||
|
return $options;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get brands as options for Elementor select control
|
||||||
|
* Returns cached results for performance
|
||||||
|
*/
|
||||||
|
public function get_brand_options() {
|
||||||
|
$cache_key = 'cannaiq_brand_options';
|
||||||
|
$cached = get_transient($cache_key);
|
||||||
|
|
||||||
|
if ($cached !== false) {
|
||||||
|
return $cached;
|
||||||
|
}
|
||||||
|
|
||||||
|
$brands = $this->fetch_brands(['limit' => 200]);
|
||||||
|
$options = ['' => __('All Brands', 'cannaiq-menus')];
|
||||||
|
|
||||||
|
if ($brands) {
|
||||||
|
foreach ($brands as $brand) {
|
||||||
|
$name = $brand['brand'] ?? $brand['brand_name'] ?? '';
|
||||||
|
if ($name) {
|
||||||
|
$options[$name] = $name;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
set_transient($cache_key, $options, 5 * MINUTE_IN_SECONDS);
|
||||||
|
return $options;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Initialize Plugin
|
// Initialize Plugin
|
||||||
|
|||||||
184
wordpress-plugin/widgets/brand-grid.php
Normal file
184
wordpress-plugin/widgets/brand-grid.php
Normal file
@@ -0,0 +1,184 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* Elementor Brand Grid Widget
|
||||||
|
*/
|
||||||
|
|
||||||
|
if (!defined('ABSPATH')) {
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
class CannaIQ_Menus_Brand_Grid_Widget extends \Elementor\Widget_Base {
|
||||||
|
|
||||||
|
public function get_name() {
|
||||||
|
return 'cannaiq_brand_grid';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function get_title() {
|
||||||
|
return __('CannaIQ Brand Grid', 'cannaiq-menus');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function get_icon() {
|
||||||
|
return 'eicon-gallery-grid';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function get_categories() {
|
||||||
|
return ['general'];
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function register_controls() {
|
||||||
|
|
||||||
|
// Content Section
|
||||||
|
$this->start_controls_section(
|
||||||
|
'content_section',
|
||||||
|
[
|
||||||
|
'label' => __('Content', 'cannaiq-menus'),
|
||||||
|
'tab' => \Elementor\Controls_Manager::TAB_CONTENT,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->add_control(
|
||||||
|
'limit',
|
||||||
|
[
|
||||||
|
'label' => __('Number of Brands', 'cannaiq-menus'),
|
||||||
|
'type' => \Elementor\Controls_Manager::NUMBER,
|
||||||
|
'default' => 12,
|
||||||
|
'min' => 1,
|
||||||
|
'max' => 100,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->add_control(
|
||||||
|
'columns',
|
||||||
|
[
|
||||||
|
'label' => __('Columns', 'cannaiq-menus'),
|
||||||
|
'type' => \Elementor\Controls_Manager::SELECT,
|
||||||
|
'default' => '4',
|
||||||
|
'options' => [
|
||||||
|
'2' => __('2 Columns', 'cannaiq-menus'),
|
||||||
|
'3' => __('3 Columns', 'cannaiq-menus'),
|
||||||
|
'4' => __('4 Columns', 'cannaiq-menus'),
|
||||||
|
'6' => __('6 Columns', 'cannaiq-menus'),
|
||||||
|
],
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->add_control(
|
||||||
|
'show_product_count',
|
||||||
|
[
|
||||||
|
'label' => __('Show Product Count', 'cannaiq-menus'),
|
||||||
|
'type' => \Elementor\Controls_Manager::SWITCHER,
|
||||||
|
'label_on' => __('Yes', 'cannaiq-menus'),
|
||||||
|
'label_off' => __('No', 'cannaiq-menus'),
|
||||||
|
'return_value' => 'yes',
|
||||||
|
'default' => 'yes',
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->add_control(
|
||||||
|
'link_to_products',
|
||||||
|
[
|
||||||
|
'label' => __('Link to Products Page', 'cannaiq-menus'),
|
||||||
|
'type' => \Elementor\Controls_Manager::URL,
|
||||||
|
'placeholder' => __('/products', 'cannaiq-menus'),
|
||||||
|
'description' => __('Brand name will be appended as ?brand=Name', 'cannaiq-menus'),
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->end_controls_section();
|
||||||
|
|
||||||
|
// Style Section
|
||||||
|
$this->start_controls_section(
|
||||||
|
'style_section',
|
||||||
|
[
|
||||||
|
'label' => __('Style', 'cannaiq-menus'),
|
||||||
|
'tab' => \Elementor\Controls_Manager::TAB_STYLE,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->add_control(
|
||||||
|
'card_background',
|
||||||
|
[
|
||||||
|
'label' => __('Card Background', 'cannaiq-menus'),
|
||||||
|
'type' => \Elementor\Controls_Manager::COLOR,
|
||||||
|
'default' => '#ffffff',
|
||||||
|
'selectors' => [
|
||||||
|
'{{WRAPPER}} .cannaiq-brand-card' => 'background-color: {{VALUE}};',
|
||||||
|
],
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->add_control(
|
||||||
|
'card_border_radius',
|
||||||
|
[
|
||||||
|
'label' => __('Border Radius', 'cannaiq-menus'),
|
||||||
|
'type' => \Elementor\Controls_Manager::SLIDER,
|
||||||
|
'size_units' => ['px'],
|
||||||
|
'range' => [
|
||||||
|
'px' => [
|
||||||
|
'min' => 0,
|
||||||
|
'max' => 50,
|
||||||
|
],
|
||||||
|
],
|
||||||
|
'default' => [
|
||||||
|
'size' => 8,
|
||||||
|
],
|
||||||
|
'selectors' => [
|
||||||
|
'{{WRAPPER}} .cannaiq-brand-card' => 'border-radius: {{SIZE}}{{UNIT}};',
|
||||||
|
],
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->add_control(
|
||||||
|
'text_color',
|
||||||
|
[
|
||||||
|
'label' => __('Text Color', 'cannaiq-menus'),
|
||||||
|
'type' => \Elementor\Controls_Manager::COLOR,
|
||||||
|
'default' => '#333333',
|
||||||
|
'selectors' => [
|
||||||
|
'{{WRAPPER}} .cannaiq-brand-card' => 'color: {{VALUE}};',
|
||||||
|
],
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->end_controls_section();
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function render() {
|
||||||
|
$settings = $this->get_settings_for_display();
|
||||||
|
|
||||||
|
$plugin = CannaIQ_Menus_Plugin::instance();
|
||||||
|
$brands = $plugin->fetch_brands(['limit' => $settings['limit']]);
|
||||||
|
|
||||||
|
if (!$brands) {
|
||||||
|
echo '<p>' . __('No brands found.', 'cannaiq-menus') . '</p>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$columns = $settings['columns'];
|
||||||
|
$link_base = $settings['link_to_products']['url'] ?? '';
|
||||||
|
?>
|
||||||
|
<div class="cannaiq-brand-grid cannaiq-grid-cols-<?php echo esc_attr($columns); ?>">
|
||||||
|
<?php foreach ($brands as $brand):
|
||||||
|
$brand_name = $brand['brand'] ?? $brand['brand_name'] ?? '';
|
||||||
|
$product_count = $brand['product_count'] ?? 0;
|
||||||
|
$brand_url = $link_base ? $link_base . '?brand=' . urlencode($brand_name) : '#';
|
||||||
|
?>
|
||||||
|
<div class="cannaiq-brand-card"
|
||||||
|
<?php if ($brand_url !== '#'): ?>onclick="window.location.href='<?php echo esc_url($brand_url); ?>'"<?php endif; ?>
|
||||||
|
style="cursor: <?php echo ($brand_url !== '#') ? 'pointer' : 'default'; ?>;">
|
||||||
|
<div class="cannaiq-brand-content">
|
||||||
|
<h3 class="cannaiq-brand-name">
|
||||||
|
<?php echo esc_html($brand_name); ?>
|
||||||
|
</h3>
|
||||||
|
<?php if ($settings['show_product_count'] === 'yes' && $product_count > 0): ?>
|
||||||
|
<span class="cannaiq-brand-count">
|
||||||
|
<?php echo esc_html($product_count); ?> <?php _e('products', 'cannaiq-menus'); ?>
|
||||||
|
</span>
|
||||||
|
<?php endif; ?>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</div>
|
||||||
|
<?php
|
||||||
|
}
|
||||||
|
}
|
||||||
205
wordpress-plugin/widgets/category-list.php
Normal file
205
wordpress-plugin/widgets/category-list.php
Normal file
@@ -0,0 +1,205 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* Elementor Category List Widget
|
||||||
|
*/
|
||||||
|
|
||||||
|
if (!defined('ABSPATH')) {
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
class CannaIQ_Menus_Category_List_Widget extends \Elementor\Widget_Base {
|
||||||
|
|
||||||
|
public function get_name() {
|
||||||
|
return 'cannaiq_category_list';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function get_title() {
|
||||||
|
return __('CannaIQ Category List', 'cannaiq-menus');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function get_icon() {
|
||||||
|
return 'eicon-bullet-list';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function get_categories() {
|
||||||
|
return ['general'];
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function register_controls() {
|
||||||
|
|
||||||
|
// Content Section
|
||||||
|
$this->start_controls_section(
|
||||||
|
'content_section',
|
||||||
|
[
|
||||||
|
'label' => __('Content', 'cannaiq-menus'),
|
||||||
|
'tab' => \Elementor\Controls_Manager::TAB_CONTENT,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->add_control(
|
||||||
|
'layout',
|
||||||
|
[
|
||||||
|
'label' => __('Layout', 'cannaiq-menus'),
|
||||||
|
'type' => \Elementor\Controls_Manager::SELECT,
|
||||||
|
'default' => 'grid',
|
||||||
|
'options' => [
|
||||||
|
'grid' => __('Grid', 'cannaiq-menus'),
|
||||||
|
'list' => __('List', 'cannaiq-menus'),
|
||||||
|
'pills' => __('Pills/Tags', 'cannaiq-menus'),
|
||||||
|
],
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->add_control(
|
||||||
|
'columns',
|
||||||
|
[
|
||||||
|
'label' => __('Columns', 'cannaiq-menus'),
|
||||||
|
'type' => \Elementor\Controls_Manager::SELECT,
|
||||||
|
'default' => '3',
|
||||||
|
'options' => [
|
||||||
|
'2' => __('2 Columns', 'cannaiq-menus'),
|
||||||
|
'3' => __('3 Columns', 'cannaiq-menus'),
|
||||||
|
'4' => __('4 Columns', 'cannaiq-menus'),
|
||||||
|
'6' => __('6 Columns', 'cannaiq-menus'),
|
||||||
|
],
|
||||||
|
'condition' => [
|
||||||
|
'layout' => 'grid',
|
||||||
|
],
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->add_control(
|
||||||
|
'show_product_count',
|
||||||
|
[
|
||||||
|
'label' => __('Show Product Count', 'cannaiq-menus'),
|
||||||
|
'type' => \Elementor\Controls_Manager::SWITCHER,
|
||||||
|
'label_on' => __('Yes', 'cannaiq-menus'),
|
||||||
|
'label_off' => __('No', 'cannaiq-menus'),
|
||||||
|
'return_value' => 'yes',
|
||||||
|
'default' => 'yes',
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->add_control(
|
||||||
|
'link_to_products',
|
||||||
|
[
|
||||||
|
'label' => __('Link to Products Page', 'cannaiq-menus'),
|
||||||
|
'type' => \Elementor\Controls_Manager::URL,
|
||||||
|
'placeholder' => __('/products', 'cannaiq-menus'),
|
||||||
|
'description' => __('Category name will be appended as ?category=Name', 'cannaiq-menus'),
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->end_controls_section();
|
||||||
|
|
||||||
|
// Style Section
|
||||||
|
$this->start_controls_section(
|
||||||
|
'style_section',
|
||||||
|
[
|
||||||
|
'label' => __('Style', 'cannaiq-menus'),
|
||||||
|
'tab' => \Elementor\Controls_Manager::TAB_STYLE,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->add_control(
|
||||||
|
'card_background',
|
||||||
|
[
|
||||||
|
'label' => __('Card Background', 'cannaiq-menus'),
|
||||||
|
'type' => \Elementor\Controls_Manager::COLOR,
|
||||||
|
'default' => '#ffffff',
|
||||||
|
'selectors' => [
|
||||||
|
'{{WRAPPER}} .cannaiq-category-item' => 'background-color: {{VALUE}};',
|
||||||
|
],
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->add_control(
|
||||||
|
'card_border_radius',
|
||||||
|
[
|
||||||
|
'label' => __('Border Radius', 'cannaiq-menus'),
|
||||||
|
'type' => \Elementor\Controls_Manager::SLIDER,
|
||||||
|
'size_units' => ['px'],
|
||||||
|
'range' => [
|
||||||
|
'px' => [
|
||||||
|
'min' => 0,
|
||||||
|
'max' => 50,
|
||||||
|
],
|
||||||
|
],
|
||||||
|
'default' => [
|
||||||
|
'size' => 8,
|
||||||
|
],
|
||||||
|
'selectors' => [
|
||||||
|
'{{WRAPPER}} .cannaiq-category-item' => 'border-radius: {{SIZE}}{{UNIT}};',
|
||||||
|
],
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->add_control(
|
||||||
|
'text_color',
|
||||||
|
[
|
||||||
|
'label' => __('Text Color', 'cannaiq-menus'),
|
||||||
|
'type' => \Elementor\Controls_Manager::COLOR,
|
||||||
|
'default' => '#333333',
|
||||||
|
'selectors' => [
|
||||||
|
'{{WRAPPER}} .cannaiq-category-item' => 'color: {{VALUE}};',
|
||||||
|
],
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->add_control(
|
||||||
|
'hover_background',
|
||||||
|
[
|
||||||
|
'label' => __('Hover Background', 'cannaiq-menus'),
|
||||||
|
'type' => \Elementor\Controls_Manager::COLOR,
|
||||||
|
'default' => '#f3f4f6',
|
||||||
|
'selectors' => [
|
||||||
|
'{{WRAPPER}} .cannaiq-category-item:hover' => 'background-color: {{VALUE}};',
|
||||||
|
],
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->end_controls_section();
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function render() {
|
||||||
|
$settings = $this->get_settings_for_display();
|
||||||
|
|
||||||
|
$plugin = CannaIQ_Menus_Plugin::instance();
|
||||||
|
$categories = $plugin->fetch_categories();
|
||||||
|
|
||||||
|
if (!$categories) {
|
||||||
|
echo '<p>' . __('No categories found.', 'cannaiq-menus') . '</p>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$layout = $settings['layout'];
|
||||||
|
$columns = $settings['columns'];
|
||||||
|
$link_base = $settings['link_to_products']['url'] ?? '';
|
||||||
|
|
||||||
|
$container_class = 'cannaiq-category-' . $layout;
|
||||||
|
if ($layout === 'grid') {
|
||||||
|
$container_class .= ' cannaiq-grid-cols-' . $columns;
|
||||||
|
}
|
||||||
|
?>
|
||||||
|
<div class="<?php echo esc_attr($container_class); ?>">
|
||||||
|
<?php foreach ($categories as $category):
|
||||||
|
$cat_name = $category['type'] ?? $category['name'] ?? '';
|
||||||
|
$display_name = ucwords(str_replace('_', ' ', $cat_name));
|
||||||
|
$product_count = $category['product_count'] ?? 0;
|
||||||
|
$cat_url = $link_base ? $link_base . '?category=' . urlencode($cat_name) : '#';
|
||||||
|
?>
|
||||||
|
<a href="<?php echo esc_url($cat_url); ?>" class="cannaiq-category-item cannaiq-category-<?php echo esc_attr($layout); ?>-item">
|
||||||
|
<span class="cannaiq-category-name">
|
||||||
|
<?php echo esc_html($display_name); ?>
|
||||||
|
</span>
|
||||||
|
<?php if ($settings['show_product_count'] === 'yes' && $product_count > 0): ?>
|
||||||
|
<span class="cannaiq-category-count">
|
||||||
|
(<?php echo esc_html($product_count); ?>)
|
||||||
|
</span>
|
||||||
|
<?php endif; ?>
|
||||||
|
</a>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</div>
|
||||||
|
<?php
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -47,12 +47,37 @@ class CannaIQ_Menus_Product_Grid_Widget extends \Elementor\Widget_Base {
|
|||||||
);
|
);
|
||||||
|
|
||||||
$this->add_control(
|
$this->add_control(
|
||||||
'category_id',
|
'category',
|
||||||
[
|
[
|
||||||
'label' => __('Category ID', 'cannaiq-menus'),
|
'label' => __('Category', 'cannaiq-menus'),
|
||||||
'type' => \Elementor\Controls_Manager::NUMBER,
|
'type' => \Elementor\Controls_Manager::SELECT,
|
||||||
'default' => '',
|
'default' => '',
|
||||||
'description' => __('Leave empty to show all categories', 'cannaiq-menus'),
|
'options' => CannaIQ_Menus_Plugin::instance()->get_category_options(),
|
||||||
|
'description' => __('Filter by product category', 'cannaiq-menus'),
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->add_control(
|
||||||
|
'brand',
|
||||||
|
[
|
||||||
|
'label' => __('Brand', 'cannaiq-menus'),
|
||||||
|
'type' => \Elementor\Controls_Manager::SELECT,
|
||||||
|
'default' => '',
|
||||||
|
'options' => CannaIQ_Menus_Plugin::instance()->get_brand_options(),
|
||||||
|
'description' => __('Filter by brand', 'cannaiq-menus'),
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->add_control(
|
||||||
|
'on_special',
|
||||||
|
[
|
||||||
|
'label' => __('On Special Only', 'cannaiq-menus'),
|
||||||
|
'type' => \Elementor\Controls_Manager::SWITCHER,
|
||||||
|
'label_on' => __('Yes', 'cannaiq-menus'),
|
||||||
|
'label_off' => __('No', 'cannaiq-menus'),
|
||||||
|
'return_value' => 'yes',
|
||||||
|
'default' => 'no',
|
||||||
|
'description' => __('Show only products on sale', 'cannaiq-menus'),
|
||||||
]
|
]
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -243,8 +268,16 @@ class CannaIQ_Menus_Product_Grid_Widget extends \Elementor\Widget_Base {
|
|||||||
'in_stock' => $settings['in_stock_only'] === 'yes' ? 'true' : 'false',
|
'in_stock' => $settings['in_stock_only'] === 'yes' ? 'true' : 'false',
|
||||||
];
|
];
|
||||||
|
|
||||||
if (!empty($settings['category_id'])) {
|
if (!empty($settings['category'])) {
|
||||||
$args['category_id'] = $settings['category_id'];
|
$args['type'] = $settings['category'];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!empty($settings['brand'])) {
|
||||||
|
$args['brandName'] = $settings['brand'];
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($settings['on_special'] === 'yes') {
|
||||||
|
$args['on_special'] = 'true';
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!empty($settings['search'])) {
|
if (!empty($settings['search'])) {
|
||||||
|
|||||||
288
wordpress-plugin/widgets/specials-grid.php
Normal file
288
wordpress-plugin/widgets/specials-grid.php
Normal file
@@ -0,0 +1,288 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* Elementor Specials/Deals Grid Widget
|
||||||
|
*/
|
||||||
|
|
||||||
|
if (!defined('ABSPATH')) {
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
class CannaIQ_Menus_Specials_Grid_Widget extends \Elementor\Widget_Base {
|
||||||
|
|
||||||
|
public function get_name() {
|
||||||
|
return 'cannaiq_specials_grid';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function get_title() {
|
||||||
|
return __('CannaIQ Specials/Deals', 'cannaiq-menus');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function get_icon() {
|
||||||
|
return 'eicon-price-table';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function get_categories() {
|
||||||
|
return ['general'];
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function register_controls() {
|
||||||
|
|
||||||
|
// Content Section
|
||||||
|
$this->start_controls_section(
|
||||||
|
'content_section',
|
||||||
|
[
|
||||||
|
'label' => __('Content', 'cannaiq-menus'),
|
||||||
|
'tab' => \Elementor\Controls_Manager::TAB_CONTENT,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->add_control(
|
||||||
|
'store_id',
|
||||||
|
[
|
||||||
|
'label' => __('Store ID', 'cannaiq-menus'),
|
||||||
|
'type' => \Elementor\Controls_Manager::NUMBER,
|
||||||
|
'default' => get_option('cannaiq_default_store_id', 1),
|
||||||
|
'min' => 1,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->add_control(
|
||||||
|
'limit',
|
||||||
|
[
|
||||||
|
'label' => __('Number of Products', 'cannaiq-menus'),
|
||||||
|
'type' => \Elementor\Controls_Manager::NUMBER,
|
||||||
|
'default' => 8,
|
||||||
|
'min' => 1,
|
||||||
|
'max' => 50,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->add_control(
|
||||||
|
'columns',
|
||||||
|
[
|
||||||
|
'label' => __('Columns', 'cannaiq-menus'),
|
||||||
|
'type' => \Elementor\Controls_Manager::SELECT,
|
||||||
|
'default' => '4',
|
||||||
|
'options' => [
|
||||||
|
'2' => __('2 Columns', 'cannaiq-menus'),
|
||||||
|
'3' => __('3 Columns', 'cannaiq-menus'),
|
||||||
|
'4' => __('4 Columns', 'cannaiq-menus'),
|
||||||
|
],
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->add_control(
|
||||||
|
'category',
|
||||||
|
[
|
||||||
|
'label' => __('Category', 'cannaiq-menus'),
|
||||||
|
'type' => \Elementor\Controls_Manager::SELECT,
|
||||||
|
'default' => '',
|
||||||
|
'options' => CannaIQ_Menus_Plugin::instance()->get_category_options(),
|
||||||
|
'description' => __('Filter specials by category', 'cannaiq-menus'),
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->end_controls_section();
|
||||||
|
|
||||||
|
// Display Options Section
|
||||||
|
$this->start_controls_section(
|
||||||
|
'display_section',
|
||||||
|
[
|
||||||
|
'label' => __('Display Options', 'cannaiq-menus'),
|
||||||
|
'tab' => \Elementor\Controls_Manager::TAB_CONTENT,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->add_control(
|
||||||
|
'show_image',
|
||||||
|
[
|
||||||
|
'label' => __('Show Image', 'cannaiq-menus'),
|
||||||
|
'type' => \Elementor\Controls_Manager::SWITCHER,
|
||||||
|
'label_on' => __('Yes', 'cannaiq-menus'),
|
||||||
|
'label_off' => __('No', 'cannaiq-menus'),
|
||||||
|
'return_value' => 'yes',
|
||||||
|
'default' => 'yes',
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->add_control(
|
||||||
|
'show_discount_badge',
|
||||||
|
[
|
||||||
|
'label' => __('Show Discount Badge', 'cannaiq-menus'),
|
||||||
|
'type' => \Elementor\Controls_Manager::SWITCHER,
|
||||||
|
'label_on' => __('Yes', 'cannaiq-menus'),
|
||||||
|
'label_off' => __('No', 'cannaiq-menus'),
|
||||||
|
'return_value' => 'yes',
|
||||||
|
'default' => 'yes',
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->add_control(
|
||||||
|
'show_original_price',
|
||||||
|
[
|
||||||
|
'label' => __('Show Original Price', 'cannaiq-menus'),
|
||||||
|
'type' => \Elementor\Controls_Manager::SWITCHER,
|
||||||
|
'label_on' => __('Yes', 'cannaiq-menus'),
|
||||||
|
'label_off' => __('No', 'cannaiq-menus'),
|
||||||
|
'return_value' => 'yes',
|
||||||
|
'default' => 'yes',
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->add_control(
|
||||||
|
'show_thc',
|
||||||
|
[
|
||||||
|
'label' => __('Show THC', 'cannaiq-menus'),
|
||||||
|
'type' => \Elementor\Controls_Manager::SWITCHER,
|
||||||
|
'label_on' => __('Yes', 'cannaiq-menus'),
|
||||||
|
'label_off' => __('No', 'cannaiq-menus'),
|
||||||
|
'return_value' => 'yes',
|
||||||
|
'default' => 'no',
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->end_controls_section();
|
||||||
|
|
||||||
|
// Style Section
|
||||||
|
$this->start_controls_section(
|
||||||
|
'style_section',
|
||||||
|
[
|
||||||
|
'label' => __('Style', 'cannaiq-menus'),
|
||||||
|
'tab' => \Elementor\Controls_Manager::TAB_STYLE,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->add_control(
|
||||||
|
'card_background',
|
||||||
|
[
|
||||||
|
'label' => __('Card Background', 'cannaiq-menus'),
|
||||||
|
'type' => \Elementor\Controls_Manager::COLOR,
|
||||||
|
'default' => '#ffffff',
|
||||||
|
'selectors' => [
|
||||||
|
'{{WRAPPER}} .cannaiq-special-card' => 'background-color: {{VALUE}};',
|
||||||
|
],
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->add_control(
|
||||||
|
'badge_background',
|
||||||
|
[
|
||||||
|
'label' => __('Badge Background', 'cannaiq-menus'),
|
||||||
|
'type' => \Elementor\Controls_Manager::COLOR,
|
||||||
|
'default' => '#ef4444',
|
||||||
|
'selectors' => [
|
||||||
|
'{{WRAPPER}} .cannaiq-discount-badge' => 'background-color: {{VALUE}};',
|
||||||
|
],
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->add_control(
|
||||||
|
'sale_price_color',
|
||||||
|
[
|
||||||
|
'label' => __('Sale Price Color', 'cannaiq-menus'),
|
||||||
|
'type' => \Elementor\Controls_Manager::COLOR,
|
||||||
|
'default' => '#16a34a',
|
||||||
|
'selectors' => [
|
||||||
|
'{{WRAPPER}} .cannaiq-price-sale' => 'color: {{VALUE}};',
|
||||||
|
],
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->add_control(
|
||||||
|
'card_border_radius',
|
||||||
|
[
|
||||||
|
'label' => __('Border Radius', 'cannaiq-menus'),
|
||||||
|
'type' => \Elementor\Controls_Manager::SLIDER,
|
||||||
|
'size_units' => ['px'],
|
||||||
|
'range' => [
|
||||||
|
'px' => [
|
||||||
|
'min' => 0,
|
||||||
|
'max' => 50,
|
||||||
|
],
|
||||||
|
],
|
||||||
|
'default' => [
|
||||||
|
'size' => 8,
|
||||||
|
],
|
||||||
|
'selectors' => [
|
||||||
|
'{{WRAPPER}} .cannaiq-special-card' => 'border-radius: {{SIZE}}{{UNIT}};',
|
||||||
|
],
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->end_controls_section();
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function render() {
|
||||||
|
$settings = $this->get_settings_for_display();
|
||||||
|
|
||||||
|
$args = [
|
||||||
|
'store_id' => $settings['store_id'],
|
||||||
|
'limit' => $settings['limit'],
|
||||||
|
];
|
||||||
|
|
||||||
|
if (!empty($settings['category'])) {
|
||||||
|
$args['type'] = $settings['category'];
|
||||||
|
}
|
||||||
|
|
||||||
|
$plugin = CannaIQ_Menus_Plugin::instance();
|
||||||
|
$products = $plugin->fetch_specials($args);
|
||||||
|
|
||||||
|
if (!$products) {
|
||||||
|
echo '<p>' . __('No specials found.', 'cannaiq-menus') . '</p>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$columns = $settings['columns'];
|
||||||
|
?>
|
||||||
|
<div class="cannaiq-specials-grid cannaiq-grid-cols-<?php echo esc_attr($columns); ?>">
|
||||||
|
<?php foreach ($products as $product):
|
||||||
|
$image_url = $product['image_url'] ?? $product['primary_image_url'] ?? '';
|
||||||
|
$product_url = !empty($product['menu_url']) ? $product['menu_url'] : '#';
|
||||||
|
$regular_price = $product['regular_price'] ?? 0;
|
||||||
|
$sale_price = $product['sale_price'] ?? $regular_price;
|
||||||
|
$discount = ($regular_price > 0 && $sale_price < $regular_price)
|
||||||
|
? round((($regular_price - $sale_price) / $regular_price) * 100)
|
||||||
|
: 0;
|
||||||
|
?>
|
||||||
|
<div class="cannaiq-special-card"
|
||||||
|
<?php if ($product_url !== '#'): ?>onclick="window.open('<?php echo esc_url($product_url); ?>', '_blank')"<?php endif; ?>
|
||||||
|
style="cursor: <?php echo ($product_url !== '#') ? 'pointer' : 'default'; ?>;">
|
||||||
|
|
||||||
|
<?php if ($settings['show_discount_badge'] === 'yes' && $discount > 0): ?>
|
||||||
|
<div class="cannaiq-discount-badge">
|
||||||
|
-<?php echo esc_html($discount); ?>%
|
||||||
|
</div>
|
||||||
|
<?php endif; ?>
|
||||||
|
|
||||||
|
<?php if ($settings['show_image'] === 'yes' && !empty($image_url)): ?>
|
||||||
|
<div class="cannaiq-special-image">
|
||||||
|
<img src="<?php echo esc_url($image_url); ?>"
|
||||||
|
alt="<?php echo esc_attr($product['name']); ?>"
|
||||||
|
loading="lazy" />
|
||||||
|
</div>
|
||||||
|
<?php endif; ?>
|
||||||
|
|
||||||
|
<div class="cannaiq-special-content">
|
||||||
|
<h3 class="cannaiq-special-title">
|
||||||
|
<?php echo esc_html($product['name']); ?>
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<?php if ($settings['show_thc'] === 'yes' && !empty($product['thc_percentage'])): ?>
|
||||||
|
<span class="cannaiq-meta-item cannaiq-thc">
|
||||||
|
THC: <?php echo esc_html($product['thc_percentage']); ?>%
|
||||||
|
</span>
|
||||||
|
<?php endif; ?>
|
||||||
|
|
||||||
|
<div class="cannaiq-special-price">
|
||||||
|
<span class="cannaiq-price-sale">$<?php echo esc_html($sale_price); ?></span>
|
||||||
|
<?php if ($settings['show_original_price'] === 'yes' && $regular_price > $sale_price): ?>
|
||||||
|
<span class="cannaiq-price-regular cannaiq-strikethrough">$<?php echo esc_html($regular_price); ?></span>
|
||||||
|
<?php endif; ?>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</div>
|
||||||
|
<?php
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user