|
| 1 | +import { __ } from '@wordpress/i18n' |
| 2 | +import React, { useEffect, useState, useMemo, useCallback } from 'react' |
| 3 | +import { X, ArrowRight } from 'lucide-react' |
| 4 | + |
| 5 | +const PROMO_URL = 'https://raw.githubusercontent.com/weDevsOfficial/wppm-util/master/promotions.json' |
| 6 | +const STORAGE_PREFIX = 'pm-promo-dismissed:' |
| 7 | +const FETCH_TIMEOUT_MS = 6000 |
| 8 | + |
| 9 | +const TZ_OFFSETS = { EST: '-05:00', EDT: '-04:00', PST: '-08:00', PDT: '-07:00', UTC: '+00:00', GMT: '+00:00' } |
| 10 | + |
| 11 | +function parsePromoDate(str) { |
| 12 | + if (!str) return NaN |
| 13 | + const m = String(str).trim().match(/^(\d{4}-\d{2}-\d{2})[ T](\d{1,2}):(\d{2}):(\d{2})(?:\s+([A-Z]{3,4}))?$/) |
| 14 | + if (!m) return Date.parse(str) |
| 15 | + const [, date, h, mm, ss, tz] = m |
| 16 | + const hour = h.padStart(2, '0') |
| 17 | + const offset = tz && TZ_OFFSETS[tz] ? TZ_OFFSETS[tz] : '+00:00' |
| 18 | + return Date.parse(`${date}T${hour}:${mm}:${ss}${offset}`) |
| 19 | +} |
| 20 | + |
| 21 | +function isWithinWindow(promo) { |
| 22 | + if (typeof window !== 'undefined' && /[?&]pm_promo_preview=1\b/.test(window.location.search)) { |
| 23 | + return true |
| 24 | + } |
| 25 | + const now = Date.now() |
| 26 | + const start = parsePromoDate(promo.start_date) |
| 27 | + const end = parsePromoDate(promo.end_date) |
| 28 | + if (!Number.isNaN(start) && now < start) return false |
| 29 | + if (!Number.isNaN(end) && now > end) return false |
| 30 | + return true |
| 31 | +} |
| 32 | + |
| 33 | +function extractDiscount(text) { |
| 34 | + if (!text) return null |
| 35 | + const m = String(text).match(/(\d{2,3})\s*%/) |
| 36 | + return m ? `${m[1]}% OFF` : null |
| 37 | +} |
| 38 | + |
| 39 | +function daysRemaining(endStr) { |
| 40 | + const end = parsePromoDate(endStr) |
| 41 | + if (Number.isNaN(end)) return null |
| 42 | + const diff = Math.ceil((end - Date.now()) / 86400000) |
| 43 | + return diff > 0 && diff <= 30 ? diff : null |
| 44 | +} |
| 45 | + |
| 46 | +export function PromoBanner({ placement = 'projects' }) { |
| 47 | + const [promo, setPromo] = useState(null) |
| 48 | + const [dismissed, setDismissed] = useState(false) |
| 49 | + |
| 50 | + const dismissKey = useMemo( |
| 51 | + () => (promo?.key ? `${STORAGE_PREFIX}${promo.key}` : null), |
| 52 | + [promo] |
| 53 | + ) |
| 54 | + |
| 55 | + useEffect(() => { |
| 56 | + let cancelled = false |
| 57 | + const controller = new AbortController() |
| 58 | + const timer = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS) |
| 59 | + |
| 60 | + fetch(PROMO_URL, { signal: controller.signal, cache: 'no-store' }) |
| 61 | + .then(r => { |
| 62 | + if (!r.ok) throw new Error(`HTTP ${r.status}`) |
| 63 | + return r.json() |
| 64 | + }) |
| 65 | + .then(data => { |
| 66 | + if (cancelled) return |
| 67 | + if (!data || !data.key) { |
| 68 | + console.warn('[PM PromoBanner] payload missing key', data) |
| 69 | + return |
| 70 | + } |
| 71 | + if (!isWithinWindow(data)) { |
| 72 | + console.info('[PM PromoBanner] promo outside window', data.key, data.start_date, data.end_date) |
| 73 | + return |
| 74 | + } |
| 75 | + const key = `${STORAGE_PREFIX}${data.key}` |
| 76 | + if (localStorage.getItem(key) === '1') { |
| 77 | + setDismissed(true) |
| 78 | + return |
| 79 | + } |
| 80 | + setPromo(data) |
| 81 | + }) |
| 82 | + .catch(err => { |
| 83 | + if (err?.name !== 'AbortError') { |
| 84 | + console.warn('[PM PromoBanner] fetch failed', err) |
| 85 | + } |
| 86 | + }) |
| 87 | + .finally(() => clearTimeout(timer)) |
| 88 | + |
| 89 | + return () => { |
| 90 | + cancelled = true |
| 91 | + controller.abort() |
| 92 | + clearTimeout(timer) |
| 93 | + } |
| 94 | + }, []) |
| 95 | + |
| 96 | + const handleDismiss = useCallback(() => { |
| 97 | + if (dismissKey) localStorage.setItem(dismissKey, '1') |
| 98 | + setDismissed(true) |
| 99 | + }, [dismissKey]) |
| 100 | + |
| 101 | + if (dismissed || !promo) return null |
| 102 | + |
| 103 | + const action = promo.action_url |
| 104 | + ? `${promo.action_url}${promo.action_url.includes('?') ? '&' : '?'}utm_content=pm-${placement}-banner` |
| 105 | + : null |
| 106 | + |
| 107 | + const discount = extractDiscount(promo.title) || extractDiscount(promo.content) |
| 108 | + const daysLeft = daysRemaining(promo.end_date) |
| 109 | + |
| 110 | + return ( |
| 111 | + <div |
| 112 | + className="pm-promo-banner relative rounded-xl border overflow-hidden" |
| 113 | + style={{ |
| 114 | + background: '#FAFAFB', |
| 115 | + borderColor: '#E5E7EB', |
| 116 | + }} |
| 117 | + > |
| 118 | + <span |
| 119 | + aria-hidden |
| 120 | + className="absolute left-0 top-0 bottom-0" |
| 121 | + style={{ width: '3px', background: '#7C3AED' }} |
| 122 | + /> |
| 123 | + |
| 124 | + <button |
| 125 | + type="button" |
| 126 | + onClick={handleDismiss} |
| 127 | + aria-label={__('Dismiss', 'wedevs-project-manager')} |
| 128 | + className="absolute top-2 right-2 z-20 p-1.5 rounded text-pm-text-muted hover:text-pm-text-primary bg-white/80 hover:bg-pm-hover backdrop-blur-sm transition-colors" |
| 129 | + > |
| 130 | + <X className="h-3.5 w-3.5" /> |
| 131 | + </button> |
| 132 | + |
| 133 | + <div className="flex flex-col sm:flex-row sm:items-center gap-3 sm:gap-5 px-5 py-4 pl-6 pr-10 sm:pr-12"> |
| 134 | + <div className="flex items-center gap-3 flex-1 min-w-0"> |
| 135 | + <img |
| 136 | + src={`${typeof PM_Vars !== 'undefined' ? PM_Vars.dir_url : '/wp-content/plugins/wedevs-project-manager/'}.wordpress-org/icon-128x128.gif`} |
| 137 | + alt="" |
| 138 | + className="shrink-0 rounded-lg" |
| 139 | + style={{ height: '56px', width: '56px' }} |
| 140 | + /> |
| 141 | + {discount && ( |
| 142 | + <span |
| 143 | + className="shrink-0 inline-flex items-baseline gap-1 px-3 py-1.5 rounded-md" |
| 144 | + style={{ |
| 145 | + background: 'linear-gradient(135deg, #FF8A00 0%, #FF5C00 100%)', |
| 146 | + color: '#fff', |
| 147 | + boxShadow: '0 4px 12px -2px rgba(255,92,0,0.45)', |
| 148 | + }} |
| 149 | + > |
| 150 | + <span style={{ fontSize: '20px', fontWeight: 800, lineHeight: 1, letterSpacing: '-0.02em' }}> |
| 151 | + {discount.replace(' OFF', '')} |
| 152 | + </span> |
| 153 | + <span style={{ fontSize: '11px', fontWeight: 700, letterSpacing: '0.08em' }}> |
| 154 | + OFF |
| 155 | + </span> |
| 156 | + </span> |
| 157 | + )} |
| 158 | + <span className="text-sm font-semibold text-pm-text-primary truncate"> |
| 159 | + {promo.title} |
| 160 | + </span> |
| 161 | + {daysLeft != null && ( |
| 162 | + <span className="hidden md:inline text-[12px] text-pm-text-muted shrink-0"> |
| 163 | + · {daysLeft === 1 |
| 164 | + ? __('ends tomorrow', 'wedevs-project-manager') |
| 165 | + : `${daysLeft} ${__('days left', 'wedevs-project-manager')}`} |
| 166 | + </span> |
| 167 | + )} |
| 168 | + </div> |
| 169 | + |
| 170 | + {action && ( |
| 171 | + <a |
| 172 | + href={action} |
| 173 | + target="_blank" |
| 174 | + rel="noopener noreferrer" |
| 175 | + className="pm-promo-cta inline-flex items-center justify-center gap-2 self-start sm:self-auto shrink-0 px-5 py-2.5 rounded-md font-semibold text-[13px] tracking-[0.01em] whitespace-nowrap no-underline transition-all" |
| 176 | + style={{ |
| 177 | + background: '#7C3AED', |
| 178 | + color: '#fff', |
| 179 | + boxShadow: '0 1px 2px rgba(124,58,237,0.25), 0 4px 12px -2px rgba(124,58,237,0.35)', |
| 180 | + border: '1px solid #6D28D9', |
| 181 | + }} |
| 182 | + onMouseEnter={e => { |
| 183 | + e.currentTarget.style.background = '#6D28D9' |
| 184 | + e.currentTarget.style.boxShadow = '0 1px 2px rgba(124,58,237,0.3), 0 8px 18px -2px rgba(124,58,237,0.5)' |
| 185 | + }} |
| 186 | + onMouseLeave={e => { |
| 187 | + e.currentTarget.style.background = '#7C3AED' |
| 188 | + e.currentTarget.style.boxShadow = '0 1px 2px rgba(124,58,237,0.25), 0 4px 12px -2px rgba(124,58,237,0.35)' |
| 189 | + }} |
| 190 | + > |
| 191 | + <span>{promo.action_title || __('Upgrade to Pro', 'wedevs-project-manager')}</span> |
| 192 | + <ArrowRight className="h-3.5 w-3.5" /> |
| 193 | + </a> |
| 194 | + )} |
| 195 | + </div> |
| 196 | + </div> |
| 197 | + ) |
| 198 | +} |
| 199 | + |
| 200 | +export default PromoBanner |
0 commit comments