fix: resolve container width issues, spa redirects, and appearance settings overwrite. feat: enhance order/sub details and newsletter layout
This commit is contained in:
@@ -53,6 +53,8 @@ import { __ } from '@/lib/i18n';
|
||||
import { ThemeToggle } from '@/components/ThemeToggle';
|
||||
import { initializeWindowAPI } from '@/lib/windowAPI';
|
||||
|
||||
import { LegacyCampaignRedirect } from '@/components/LegacyCampaignRedirect';
|
||||
|
||||
function useFullscreen() {
|
||||
const [on, setOn] = useState<boolean>(() => {
|
||||
try { return localStorage.getItem('wnwFullscreen') === '1'; } catch { return false; }
|
||||
@@ -261,6 +263,7 @@ import SettingsPayments from '@/routes/Settings/Payments';
|
||||
import SettingsShipping from '@/routes/Settings/Shipping';
|
||||
import SettingsTax from '@/routes/Settings/Tax';
|
||||
import SettingsCustomers from '@/routes/Settings/Customers';
|
||||
import SettingsSecurity from '@/routes/Settings/Security';
|
||||
import SettingsLocalPickup from '@/routes/Settings/LocalPickup';
|
||||
import SettingsNotifications from '@/routes/Settings/Notifications';
|
||||
import StaffNotifications from '@/routes/Settings/Notifications/Staff';
|
||||
@@ -287,7 +290,9 @@ import AppearanceAccount from '@/routes/Appearance/Account';
|
||||
import AppearanceMenus from '@/routes/Appearance/Menus/MenuEditor';
|
||||
import AppearancePages from '@/routes/Appearance/Pages';
|
||||
import MarketingIndex from '@/routes/Marketing';
|
||||
import Newsletter from '@/routes/Marketing/Newsletter';
|
||||
import NewsletterLayout from '@/routes/Marketing/Newsletter';
|
||||
import NewsletterSubscribers from '@/routes/Marketing/Newsletter/Subscribers';
|
||||
import NewsletterCampaignsList from '@/routes/Marketing/Campaigns';
|
||||
import CampaignEdit from '@/routes/Marketing/Campaigns/Edit';
|
||||
import MorePage from '@/routes/More';
|
||||
import Help from '@/routes/Help';
|
||||
@@ -620,6 +625,7 @@ function AppRoutes() {
|
||||
<Route path="/settings/shipping" element={<SettingsShipping />} />
|
||||
<Route path="/settings/tax" element={<SettingsTax />} />
|
||||
<Route path="/settings/customers" element={<SettingsCustomers />} />
|
||||
<Route path="/settings/security" element={<SettingsSecurity />} />
|
||||
<Route path="/settings/taxes" element={<Navigate to="/settings/tax" replace />} />
|
||||
<Route path="/settings/local-pickup" element={<SettingsLocalPickup />} />
|
||||
<Route path="/settings/checkout" element={<SettingsIndex />} />
|
||||
@@ -653,8 +659,17 @@ function AppRoutes() {
|
||||
|
||||
{/* Marketing */}
|
||||
<Route path="/marketing" element={<MarketingIndex />} />
|
||||
<Route path="/marketing/newsletter" element={<Newsletter />} />
|
||||
<Route path="/marketing/newsletter/campaigns/:id" element={<CampaignEdit />} />
|
||||
<Route path="/marketing/newsletter" element={<NewsletterLayout />}>
|
||||
<Route index element={<Navigate to="subscribers" replace />} />
|
||||
<Route path="subscribers" element={<NewsletterSubscribers />} />
|
||||
<Route path="campaigns" element={<NewsletterCampaignsList />} />
|
||||
<Route path="campaigns/:id" element={<CampaignEdit />} />
|
||||
</Route>
|
||||
|
||||
{/* Legacy Redirects for Newsletter (using component to preserve params) */}
|
||||
<Route path="/marketing/campaigns" element={<Navigate to="/marketing/newsletter/campaigns" replace />} />
|
||||
<Route path="/marketing/campaigns/new" element={<Navigate to="/marketing/newsletter/campaigns/new" replace />} />
|
||||
<Route path="/marketing/campaigns/:id" element={<LegacyCampaignRedirect />} />
|
||||
|
||||
{/* Help - Main menu route with no submenu */}
|
||||
<Route path="/help" element={<Help />} />
|
||||
|
||||
@@ -89,7 +89,7 @@ export function BlockRenderer({
|
||||
return (
|
||||
<div style={cardStyles[block.cardType]}>
|
||||
<div
|
||||
className="prose prose-sm max-w-none [&_h1]:text-3xl [&_h1]:font-bold [&_h1]:mt-0 [&_h1]:mb-4 [&_h2]:text-2xl [&_h2]:font-bold [&_h2]:mt-0 [&_h2]:mb-3 [&_h3]:text-xl [&_h3]:font-bold [&_h3]:mt-0 [&_h3]:mb-2 [&_h4]:text-lg [&_h4]:font-bold [&_h4]:mt-0 [&_h4]:mb-2 [&_.button]:inline-block [&_.button]:bg-purple-600 [&_.button]:text-white [&_.button]:px-7 [&_.button]:py-3.5 [&_.button]:rounded-md [&_.button]:no-underline [&_.button]:font-semibold [&_.button-outline]:inline-block [&_.button-outline]:bg-transparent [&_.button-outline]:text-purple-600 [&_.button-outline]:px-6 [&_.button-outline]:py-3 [&_.button-outline]:rounded-md [&_.button-outline]:no-underline [&_.button-outline]:font-semibold [&_.button-outline]:border-2 [&_.button-outline]:border-purple-600"
|
||||
className="prose prose-sm max-w-none [&_h1]:text-3xl [&_h1]:font-bold [&_h1]:mt-0 [&_h1]:mb-4 [&_h2]:text-2xl [&_h2]:font-bold [&_h2]:mt-0 [&_h2]:mb-3 [&_h3]:text-xl [&_h3]:font-bold [&_h3]:mt-0 [&_h3]:mb-2 [&_h4]:text-lg [&_h4]:font-bold [&_h4]:mt-0 [&_h4]:mb-2 [&_.button]:inline-block [&_.button]:bg-purple-600 [&_.button]:text-white [&_.button]:px-7 [&_.button]:py-3.5 [&_.button]:rounded-md [&_.button]:no-underline [&_.button]:font-semibold [&_.button-outline]:inline-block [&_.button-outline]:bg-transparent [&_.button-outline]:text-purple-600 [&_.button-outline]:px-6 [&_.button-outline]:py-3 [&_.button-outline]:rounded-md [&_.button-outline]:no-underline [&_.button-outline]:font-semibold [&_.button-outline]:border-2 [&_.button-outline]:border-purple-600 [&_.text-link]:text-purple-600 [&_.text-link]:underline"
|
||||
style={block.cardType === 'hero' ? { color: '#fff' } : {}}
|
||||
dangerouslySetInnerHTML={{ __html: htmlContent }}
|
||||
/>
|
||||
@@ -97,17 +97,17 @@ export function BlockRenderer({
|
||||
);
|
||||
|
||||
case 'button': {
|
||||
const buttonStyle: React.CSSProperties = block.style === 'solid'
|
||||
? {
|
||||
display: 'inline-block',
|
||||
background: 'var(--wn-primary, #7f54b3)',
|
||||
color: '#fff',
|
||||
padding: '14px 28px',
|
||||
borderRadius: '6px',
|
||||
textDecoration: 'none',
|
||||
fontWeight: 600,
|
||||
}
|
||||
: {
|
||||
// Different styles based on button type
|
||||
let buttonStyle: React.CSSProperties;
|
||||
|
||||
if (block.style === 'link') {
|
||||
// Plain link style - just underlined text
|
||||
buttonStyle = {
|
||||
color: 'var(--wn-primary, #7f54b3)',
|
||||
textDecoration: 'underline',
|
||||
};
|
||||
} else if (block.style === 'outline') {
|
||||
buttonStyle = {
|
||||
display: 'inline-block',
|
||||
background: 'transparent',
|
||||
color: 'var(--wn-secondary, #7f54b3)',
|
||||
@@ -117,18 +117,33 @@ export function BlockRenderer({
|
||||
textDecoration: 'none',
|
||||
fontWeight: 600,
|
||||
};
|
||||
} else {
|
||||
// Solid style (default)
|
||||
buttonStyle = {
|
||||
display: 'inline-block',
|
||||
background: 'var(--wn-primary, #7f54b3)',
|
||||
color: '#fff',
|
||||
padding: '14px 28px',
|
||||
borderRadius: '6px',
|
||||
textDecoration: 'none',
|
||||
fontWeight: 600,
|
||||
};
|
||||
}
|
||||
|
||||
const containerStyle: React.CSSProperties = {
|
||||
textAlign: block.align || 'center',
|
||||
};
|
||||
|
||||
if (block.widthMode === 'full') {
|
||||
buttonStyle.display = 'block';
|
||||
buttonStyle.width = '100%';
|
||||
buttonStyle.textAlign = 'center';
|
||||
} else if (block.widthMode === 'custom' && block.customMaxWidth) {
|
||||
buttonStyle.maxWidth = `${block.customMaxWidth}px`;
|
||||
buttonStyle.width = '100%';
|
||||
// Width modes don't apply to plain links
|
||||
if (block.style !== 'link') {
|
||||
if (block.widthMode === 'full') {
|
||||
buttonStyle.display = 'block';
|
||||
buttonStyle.width = '100%';
|
||||
buttonStyle.textAlign = 'center';
|
||||
} else if (block.widthMode === 'custom' && block.customMaxWidth) {
|
||||
buttonStyle.maxWidth = `${block.customMaxWidth}px`;
|
||||
buttonStyle.width = '100%';
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
|
||||
@@ -101,7 +101,6 @@ export function EmailBuilder({ blocks, onChange, variables = [] }: EmailBuilderP
|
||||
};
|
||||
|
||||
const openEditDialog = (block: EmailBlock) => {
|
||||
console.log('[EmailBuilder] openEditDialog called', { blockId: block.id, blockType: block.type });
|
||||
setEditingBlockId(block.id);
|
||||
|
||||
if (block.type === 'card') {
|
||||
@@ -123,7 +122,6 @@ export function EmailBuilder({ blocks, onChange, variables = [] }: EmailBuilderP
|
||||
setEditingAlign(block.align);
|
||||
}
|
||||
|
||||
console.log('[EmailBuilder] Setting editDialogOpen to true');
|
||||
setEditDialogOpen(true);
|
||||
};
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ export type BlockType = 'card' | 'button' | 'divider' | 'spacer' | 'image';
|
||||
|
||||
export type CardType = 'default' | 'success' | 'info' | 'warning' | 'hero' | 'basic';
|
||||
|
||||
export type ButtonStyle = 'solid' | 'outline';
|
||||
export type ButtonStyle = 'solid' | 'outline' | 'link';
|
||||
|
||||
export type ContentWidth = 'fit' | 'full' | 'custom';
|
||||
|
||||
|
||||
10
admin-spa/src/components/LegacyCampaignRedirect.tsx
Normal file
10
admin-spa/src/components/LegacyCampaignRedirect.tsx
Normal file
@@ -0,0 +1,10 @@
|
||||
import { Navigate, useParams } from 'react-router-dom';
|
||||
|
||||
/**
|
||||
* Legacy redirect for campaign details
|
||||
* Redirects /marketing/campaigns/:id -> /marketing/newsletter/campaigns/:id
|
||||
*/
|
||||
export function LegacyCampaignRedirect() {
|
||||
const { id } = useParams();
|
||||
return <Navigate to={`/marketing/newsletter/campaigns/${id}`} replace />;
|
||||
}
|
||||
@@ -20,7 +20,7 @@ function fmt(d: Date): string {
|
||||
}
|
||||
|
||||
export default function DateRange({ value, onChange }: Props) {
|
||||
const [preset, setPreset] = useState<string>(() => "last7");
|
||||
const [preset, setPreset] = useState<string>(() => "last30");
|
||||
const [start, setStart] = useState<string | undefined>(value?.date_start);
|
||||
const [end, setEnd] = useState<string | undefined>(value?.date_end);
|
||||
|
||||
@@ -32,8 +32,8 @@ export default function DateRange({ value, onChange }: Props) {
|
||||
return {
|
||||
today: { date_start: todayStr, date_end: todayStr },
|
||||
last7: { date_start: fmt(last7), date_end: todayStr },
|
||||
last30:{ date_start: fmt(last30), date_end: todayStr },
|
||||
custom:{ date_start: start, date_end: end },
|
||||
last30: { date_start: fmt(last30), date_end: todayStr },
|
||||
custom: { date_start: start, date_end: end },
|
||||
};
|
||||
}, [start, end]);
|
||||
|
||||
@@ -41,7 +41,7 @@ export default function DateRange({ value, onChange }: Props) {
|
||||
if (preset === "custom") {
|
||||
onChange?.({ date_start: start, date_end: end, preset });
|
||||
} else {
|
||||
const pr = (presets as any)[preset] || presets.last7;
|
||||
const pr = (presets as any)[preset] || presets.last30;
|
||||
onChange?.({ ...pr, preset });
|
||||
setStart(pr.date_start);
|
||||
setEnd(pr.date_end);
|
||||
@@ -53,7 +53,7 @@ export default function DateRange({ value, onChange }: Props) {
|
||||
<div className="flex flex-col lg:flex-row gap-2 w-full">
|
||||
<Select value={preset} onValueChange={(v) => setPreset(v)}>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue placeholder={__("Last 7 days")} />
|
||||
<SelectValue placeholder={__("Last 30 days")} />
|
||||
</SelectTrigger>
|
||||
<SelectContent position="popper" className="z-[1000]">
|
||||
<SelectGroup>
|
||||
|
||||
@@ -86,7 +86,6 @@ export function RichTextEditor({
|
||||
const currentContent = editor.getHTML();
|
||||
// Only update if content is different (avoid infinite loops)
|
||||
if (content !== currentContent) {
|
||||
console.log('RichTextEditor: Updating content', { content, currentContent });
|
||||
editor.commands.setContent(content);
|
||||
}
|
||||
}
|
||||
@@ -113,7 +112,7 @@ export function RichTextEditor({
|
||||
const [buttonDialogOpen, setButtonDialogOpen] = useState(false);
|
||||
const [buttonText, setButtonText] = useState('Click Here');
|
||||
const [buttonHref, setButtonHref] = useState('{order_url}');
|
||||
const [buttonStyle, setButtonStyle] = useState<'solid' | 'outline'>('solid');
|
||||
const [buttonStyle, setButtonStyle] = useState<'solid' | 'outline' | 'link'>('solid');
|
||||
const [isEditingButton, setIsEditingButton] = useState(false);
|
||||
const [editingButtonPos, setEditingButtonPos] = useState<number | null>(null);
|
||||
|
||||
@@ -388,12 +387,12 @@ export function RichTextEditor({
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{/* Customer Variables */}
|
||||
{variables.some(v => v.startsWith('customer') || v.includes('_name') && !v.startsWith('order') && !v.startsWith('site')) && (
|
||||
{/* Subscriber/Customer Variables */}
|
||||
{variables.some(v => v.startsWith('customer') || v.startsWith('subscriber') || (v.includes('_name') && !v.startsWith('order') && !v.startsWith('site') && !v.startsWith('store'))) && (
|
||||
<div>
|
||||
<div className="text-[10px] uppercase text-muted-foreground mb-1.5 font-medium">{__('Customer')}</div>
|
||||
<div className="text-[10px] uppercase text-muted-foreground mb-1.5 font-medium">{__('Subscriber')}</div>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{variables.filter(v => v.startsWith('customer') || (v.includes('address') && !v.startsWith('shipping'))).map((variable) => (
|
||||
{variables.filter(v => v.startsWith('customer') || v.startsWith('subscriber') || (v.includes('address') && !v.startsWith('shipping'))).map((variable) => (
|
||||
<button
|
||||
key={variable}
|
||||
type="button"
|
||||
@@ -425,11 +424,11 @@ export function RichTextEditor({
|
||||
</div>
|
||||
)}
|
||||
{/* Store/Site Variables */}
|
||||
{variables.some(v => v.startsWith('site') || v.startsWith('store') || v.startsWith('shop') || v.includes('_url') || v.startsWith('support') || v.startsWith('review')) && (
|
||||
{variables.some(v => v.startsWith('site') || v.startsWith('store') || v.startsWith('shop') || v.startsWith('current') || v.includes('_url') || v.startsWith('support') || v.startsWith('review')) && (
|
||||
<div>
|
||||
<div className="text-[10px] uppercase text-muted-foreground mb-1.5 font-medium">{__('Store & Links')}</div>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{variables.filter(v => v.startsWith('site') || v.startsWith('store') || v.startsWith('shop') || v.startsWith('my_account') || v.startsWith('support') || v.startsWith('review') || (v.includes('_url') && !v.startsWith('order') && !v.startsWith('tracking') && !v.startsWith('payment'))).map((variable) => (
|
||||
{variables.filter(v => v.startsWith('site') || v.startsWith('store') || v.startsWith('shop') || v.startsWith('current') || v.startsWith('my_account') || v.startsWith('support') || v.startsWith('review') || (v.includes('_url') && !v.startsWith('order') && !v.startsWith('tracking') && !v.startsWith('payment'))).map((variable) => (
|
||||
<button
|
||||
key={variable}
|
||||
type="button"
|
||||
@@ -501,13 +500,14 @@ export function RichTextEditor({
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="btn-style">{__('Button Style')}</Label>
|
||||
<Select value={buttonStyle} onValueChange={(value: 'solid' | 'outline') => setButtonStyle(value)}>
|
||||
<Select value={buttonStyle} onValueChange={(value: 'solid' | 'outline' | 'link') => setButtonStyle(value)}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="solid">{__('Solid (Primary)')}</SelectItem>
|
||||
<SelectItem value="outline">{__('Outline (Secondary)')}</SelectItem>
|
||||
<SelectItem value="link">{__('Plain Link')}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
@@ -7,7 +7,7 @@ export interface ButtonOptions {
|
||||
declare module '@tiptap/core' {
|
||||
interface Commands<ReturnType> {
|
||||
button: {
|
||||
setButton: (options: { text: string; href: string; style?: 'solid' | 'outline' }) => ReturnType;
|
||||
setButton: (options: { text: string; href: string; style?: 'solid' | 'outline' | 'link' }) => ReturnType;
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -70,20 +70,27 @@ export const ButtonExtension = Node.create<ButtonOptions>({
|
||||
renderHTML({ HTMLAttributes }) {
|
||||
const { text, href, style } = HTMLAttributes;
|
||||
|
||||
// Simple link styling - no fancy button appearance in editor
|
||||
// The actual button styling happens in email rendering (EmailRenderer.php)
|
||||
// In editor, just show as a styled link (differentiable from regular links)
|
||||
// Different styling based on button style
|
||||
let inlineStyle: string;
|
||||
if (style === 'link') {
|
||||
// Plain link - just underlined text, no button-like appearance
|
||||
inlineStyle = 'color: #7f54b3; text-decoration: underline; cursor: pointer;';
|
||||
} else {
|
||||
// Solid/Outline buttons - show as styled link with background hint
|
||||
inlineStyle = 'color: #7f54b3; text-decoration: underline; cursor: pointer; font-weight: 600; background: rgba(127,84,179,0.1); padding: 2px 6px; border-radius: 3px;';
|
||||
}
|
||||
|
||||
return [
|
||||
'a',
|
||||
mergeAttributes(this.options.HTMLAttributes, {
|
||||
href,
|
||||
class: 'button-node',
|
||||
style: 'color: #7f54b3; text-decoration: underline; cursor: pointer; font-weight: 600; background: rgba(127,84,179,0.1); padding: 2px 6px; border-radius: 3px;',
|
||||
class: style === 'link' ? 'link-node' : 'button-node',
|
||||
style: inlineStyle,
|
||||
'data-button': '',
|
||||
'data-text': text,
|
||||
'data-href': href,
|
||||
'data-style': style,
|
||||
title: `Button: ${text} → ${href}`,
|
||||
title: style === 'link' ? `Link: ${text}` : `Button: ${text} → ${href}`,
|
||||
}),
|
||||
text,
|
||||
];
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
/* Import design tokens for UI sizing and control defaults */
|
||||
@import './components/ui/tokens.css';
|
||||
|
||||
/* stylelint-disable at-rule-no-unknown */
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
@@ -82,12 +83,15 @@
|
||||
}
|
||||
|
||||
/* Override WordPress common.css focus/active styles */
|
||||
/* Override WordPress common.css focus/active styles */
|
||||
/* Reverting this override as it causes issues with our custom button styles
|
||||
a:focus,
|
||||
a:active {
|
||||
outline: none !important;
|
||||
box-shadow: none !important;
|
||||
color: inherit !important;
|
||||
}
|
||||
*/
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
@@ -258,12 +262,8 @@
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
/* Optional page presets (opt-in by adding the class to a wrapper before printing) */
|
||||
.print-a4 {}
|
||||
|
||||
.print-letter {}
|
||||
|
||||
.print-4x6 {}
|
||||
/* Optional page presets (opt-in by adding the class to a wrapper before printing)
|
||||
These classes are used dynamically and styled via @media print rules below */
|
||||
|
||||
@media print {
|
||||
|
||||
@@ -302,7 +302,7 @@
|
||||
color: white !important;
|
||||
}
|
||||
|
||||
.print-letter {}
|
||||
/* Letter format - extend as needed */
|
||||
|
||||
/* Thermal label (4x6in) with minimal margins */
|
||||
.print-4x6 {
|
||||
|
||||
@@ -8,11 +8,27 @@ export function htmlToMarkdown(html: string): string {
|
||||
|
||||
let markdown = html;
|
||||
|
||||
// Headings
|
||||
markdown = markdown.replace(/<h1>(.*?)<\/h1>/gi, '# $1\n\n');
|
||||
markdown = markdown.replace(/<h2>(.*?)<\/h2>/gi, '## $1\n\n');
|
||||
markdown = markdown.replace(/<h3>(.*?)<\/h3>/gi, '### $1\n\n');
|
||||
markdown = markdown.replace(/<h4>(.*?)<\/h4>/gi, '#### $1\n\n');
|
||||
// Store aligned headings for preservation
|
||||
const alignedHeadings: { [key: string]: string } = {};
|
||||
let headingIndex = 0;
|
||||
|
||||
// Process headings with potential style attributes
|
||||
for (let level = 1; level <= 4; level++) {
|
||||
const hashes = '#'.repeat(level);
|
||||
markdown = markdown.replace(new RegExp(`<h${level}([^>]*)>(.*?)</h${level}>`, 'gis'), (match, attrs, content) => {
|
||||
// Check for text-align in style attribute
|
||||
const alignMatch = attrs.match(/text-align:\s*(center|right)/i);
|
||||
if (alignMatch) {
|
||||
const align = alignMatch[1].toLowerCase();
|
||||
const placeholder = `[[HEADING${headingIndex}]]`;
|
||||
alignedHeadings[placeholder] = `<h${level} style="text-align: ${align};">${content}</h${level}>`;
|
||||
headingIndex++;
|
||||
return placeholder + '\n\n';
|
||||
}
|
||||
// No alignment, convert to markdown
|
||||
return `${hashes} ${content}\n\n`;
|
||||
});
|
||||
}
|
||||
|
||||
// Bold
|
||||
markdown = markdown.replace(/<strong>(.*?)<\/strong>/gi, '**$1**');
|
||||
@@ -100,6 +116,11 @@ export function htmlToMarkdown(html: string): string {
|
||||
markdown = markdown.replace(placeholder, html);
|
||||
});
|
||||
|
||||
// Restore aligned headings
|
||||
Object.entries(alignedHeadings).forEach(([placeholder, html]) => {
|
||||
markdown = markdown.replace(placeholder, html);
|
||||
});
|
||||
|
||||
// Clean up excessive newlines
|
||||
markdown = markdown.replace(/\n{3,}/g, '\n\n');
|
||||
|
||||
|
||||
@@ -96,15 +96,22 @@ export function markdownToHtml(markdown: string): string {
|
||||
});
|
||||
|
||||
// Parse [button:style](url)Text[/button] (new syntax)
|
||||
// Buttons are inline in TipTap, so don't wrap in <p>
|
||||
html = html.replace(/\[button:(\w+)\]\(([^)]+)\)([^\[]+)\[\/button\]/g, (match, style, url, text) => {
|
||||
if (style === 'link') {
|
||||
return `<a href="${url}" class="text-link">${text.trim()}</a>`;
|
||||
}
|
||||
const buttonClass = style === 'outline' ? 'button-outline' : 'button';
|
||||
return `<p><a href="${url}" class="${buttonClass}">${text.trim()}</a></p>`;
|
||||
return `<a href="${url}" class="${buttonClass}">${text.trim()}</a>`;
|
||||
});
|
||||
|
||||
// Parse [button url="..."] shortcodes (old syntax - backward compatibility)
|
||||
html = html.replace(/\[button\s+url="([^"]+)"(?:\s+style="([^"]+)")?\]([^\[]+)\[\/button\]/g, (match, url, style, text) => {
|
||||
if (style === 'link') {
|
||||
return `<a href="${url}" class="text-link">${text.trim()}</a>`;
|
||||
}
|
||||
const buttonClass = style === 'outline' ? 'button-outline' : 'button';
|
||||
return `<p><a href="${url}" class="${buttonClass}">${text.trim()}</a></p>`;
|
||||
return `<a href="${url}" class="${buttonClass}">${text.trim()}</a>`;
|
||||
});
|
||||
|
||||
// Parse remaining markdown
|
||||
@@ -153,8 +160,11 @@ export function parseMarkdownBasics(text: string): string {
|
||||
// Allow whitespace and newlines between parts
|
||||
// Include data-button attributes for TipTap recognition
|
||||
html = html.replace(/\[button:(\w+)\]\(([^)]+)\)([\s\S]*?)\[\/button\]/g, (match, style, url, text) => {
|
||||
const buttonClass = style === 'outline' ? 'button-outline' : 'button';
|
||||
const trimmedText = text.trim();
|
||||
if (style === 'link') {
|
||||
return `<a href="${url}" class="text-link" data-button="" data-text="${trimmedText}" data-href="${url}" data-style="link">${trimmedText}</a>`;
|
||||
}
|
||||
const buttonClass = style === 'outline' ? 'button-outline' : 'button';
|
||||
return `<a href="${url}" class="${buttonClass}" data-button="" data-text="${trimmedText}" data-href="${url}" data-style="${style}">${trimmedText}</a>`;
|
||||
});
|
||||
|
||||
|
||||
@@ -8,10 +8,12 @@ import { Switch } from '@/components/ui/switch';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { Plus, X } from 'lucide-react';
|
||||
import { Plus, X, Upload, Trash2 } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import { api } from '@/lib/api';
|
||||
import { useModules } from '@/hooks/useModules';
|
||||
import { MediaUploader } from '@/components/MediaUploader';
|
||||
import { __ } from '@/lib/i18n';
|
||||
|
||||
interface SocialLink {
|
||||
id: string;
|
||||
@@ -36,18 +38,37 @@ interface ContactData {
|
||||
show_address: boolean;
|
||||
}
|
||||
|
||||
interface PaymentMethod {
|
||||
id: string;
|
||||
url: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
export default function AppearanceFooter() {
|
||||
const { isEnabled, isLoading: modulesLoading } = useModules();
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [columns, setColumns] = useState('4');
|
||||
const [style, setStyle] = useState('detailed');
|
||||
const [copyrightText, setCopyrightText] = useState('© 2024 WooNooW. All rights reserved.');
|
||||
|
||||
|
||||
const [copyright, setCopyright] = useState({
|
||||
enabled: true,
|
||||
text: '© 2024 WooNooW. All rights reserved.',
|
||||
});
|
||||
|
||||
const [payment, setPayment] = useState<{
|
||||
enabled: boolean;
|
||||
title: string;
|
||||
methods: PaymentMethod[];
|
||||
}>({
|
||||
enabled: true,
|
||||
title: 'We accept',
|
||||
methods: []
|
||||
});
|
||||
|
||||
// Legacy elements toggle (only for newsletter, social, menu, contact)
|
||||
const [elements, setElements] = useState({
|
||||
newsletter: true,
|
||||
social: true,
|
||||
payment: true,
|
||||
copyright: true,
|
||||
menu: true,
|
||||
contact: true,
|
||||
});
|
||||
@@ -62,19 +83,16 @@ export default function AppearanceFooter() {
|
||||
show_phone: true,
|
||||
show_address: true,
|
||||
});
|
||||
|
||||
|
||||
const defaultSections: FooterSection[] = [
|
||||
{ id: '1', title: 'Contact', type: 'contact', content: '', visible: true },
|
||||
{ id: '2', title: 'Quick Links', type: 'menu', content: '', visible: true },
|
||||
{ id: '3', title: 'Follow Us', type: 'social', content: '', visible: true },
|
||||
{ id: '4', title: 'Newsletter', type: 'newsletter', content: '', visible: true },
|
||||
];
|
||||
|
||||
|
||||
// Only keeping newsletter_description, titles are now managed per column
|
||||
const [labels, setLabels] = useState({
|
||||
contact_title: 'Contact',
|
||||
menu_title: 'Quick Links',
|
||||
social_title: 'Follow Us',
|
||||
newsletter_title: 'Newsletter',
|
||||
newsletter_description: 'Subscribe to get updates',
|
||||
});
|
||||
|
||||
@@ -83,12 +101,34 @@ export default function AppearanceFooter() {
|
||||
try {
|
||||
const response = await api.get('/appearance/settings');
|
||||
const footer = response.data?.footer;
|
||||
|
||||
|
||||
if (footer) {
|
||||
if (footer.columns) setColumns(footer.columns);
|
||||
if (footer.style) setStyle(footer.style);
|
||||
if (footer.copyright_text) setCopyrightText(footer.copyright_text);
|
||||
if (footer.elements) setElements(footer.elements);
|
||||
|
||||
// Handle new structure vs backward compatibility
|
||||
if (footer.copyright) {
|
||||
setCopyright(footer.copyright);
|
||||
} else if (footer.copyright_text) {
|
||||
// Migration fallback
|
||||
setCopyright({
|
||||
enabled: footer.elements?.copyright ?? true,
|
||||
text: footer.copyright_text
|
||||
});
|
||||
}
|
||||
|
||||
if (footer.payment) {
|
||||
setPayment(footer.payment);
|
||||
} else if (footer.elements?.payment) {
|
||||
// Migration fallback
|
||||
setPayment(prev => ({ ...prev, enabled: footer.elements.payment }));
|
||||
}
|
||||
|
||||
if (footer.elements) {
|
||||
const { payment, copyright, ...rest } = footer.elements;
|
||||
setElements(prev => ({ ...prev, ...rest }));
|
||||
}
|
||||
|
||||
if (footer.social_links) setSocialLinks(footer.social_links);
|
||||
if (footer.sections && footer.sections.length > 0) {
|
||||
setSections(footer.sections);
|
||||
@@ -96,11 +136,15 @@ export default function AppearanceFooter() {
|
||||
setSections(defaultSections);
|
||||
}
|
||||
if (footer.contact_data) setContactData(footer.contact_data);
|
||||
if (footer.labels) setLabels(footer.labels);
|
||||
|
||||
// Only sync description if it exists
|
||||
if (footer.labels?.newsletter_description) {
|
||||
setLabels({ newsletter_description: footer.labels.newsletter_description });
|
||||
}
|
||||
} else {
|
||||
setSections(defaultSections);
|
||||
}
|
||||
|
||||
|
||||
// Fetch store identity data
|
||||
try {
|
||||
const identityResponse = await api.get('/settings/store-identity');
|
||||
@@ -122,7 +166,7 @@ export default function AppearanceFooter() {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
loadSettings();
|
||||
}, []);
|
||||
|
||||
@@ -152,7 +196,7 @@ export default function AppearanceFooter() {
|
||||
...sections,
|
||||
{
|
||||
id: Date.now().toString(),
|
||||
title: 'New Section',
|
||||
title: 'New Column',
|
||||
type: 'custom',
|
||||
content: '',
|
||||
visible: true,
|
||||
@@ -168,12 +212,34 @@ export default function AppearanceFooter() {
|
||||
setSections(sections.map(s => s.id === id ? { ...s, [field]: value } : s));
|
||||
};
|
||||
|
||||
const addPaymentMethod = () => {
|
||||
setPayment({
|
||||
...payment,
|
||||
methods: [...payment.methods, { id: Date.now().toString(), url: '', label: '' }]
|
||||
});
|
||||
};
|
||||
|
||||
const removePaymentMethod = (id: string) => {
|
||||
setPayment({
|
||||
...payment,
|
||||
methods: payment.methods.filter(m => m.id !== id)
|
||||
});
|
||||
};
|
||||
|
||||
const updatePaymentMethod = (id: string, field: keyof PaymentMethod, value: string) => {
|
||||
setPayment({
|
||||
...payment,
|
||||
methods: payment.methods.map(m => m.id === id ? { ...m, [field]: value } : m)
|
||||
});
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
try {
|
||||
const payload = {
|
||||
columns,
|
||||
style,
|
||||
copyrightText,
|
||||
copyright,
|
||||
payment,
|
||||
elements,
|
||||
socialLinks,
|
||||
sections,
|
||||
@@ -227,177 +293,127 @@ export default function AppearanceFooter() {
|
||||
</SettingsSection>
|
||||
</SettingsCard>
|
||||
|
||||
{/* Labels */}
|
||||
{/* Content & Contact */}
|
||||
<SettingsCard
|
||||
title="Section Labels"
|
||||
description="Customize footer section headings and text"
|
||||
title="Content & Contact"
|
||||
description="Manage footer content and contact details"
|
||||
>
|
||||
<SettingsSection label="Contact Title" htmlFor="contact-title">
|
||||
<Input
|
||||
id="contact-title"
|
||||
value={labels.contact_title}
|
||||
onChange={(e) => setLabels({ ...labels, contact_title: e.target.value })}
|
||||
placeholder="Contact"
|
||||
/>
|
||||
</SettingsSection>
|
||||
|
||||
<SettingsSection label="Menu Title" htmlFor="menu-title">
|
||||
<Input
|
||||
id="menu-title"
|
||||
value={labels.menu_title}
|
||||
onChange={(e) => setLabels({ ...labels, menu_title: e.target.value })}
|
||||
placeholder="Quick Links"
|
||||
/>
|
||||
</SettingsSection>
|
||||
|
||||
<SettingsSection label="Social Title" htmlFor="social-title">
|
||||
<Input
|
||||
id="social-title"
|
||||
value={labels.social_title}
|
||||
onChange={(e) => setLabels({ ...labels, social_title: e.target.value })}
|
||||
placeholder="Follow Us"
|
||||
/>
|
||||
</SettingsSection>
|
||||
|
||||
<SettingsSection label="Newsletter Title" htmlFor="newsletter-title">
|
||||
<Input
|
||||
id="newsletter-title"
|
||||
value={labels.newsletter_title}
|
||||
onChange={(e) => setLabels({ ...labels, newsletter_title: e.target.value })}
|
||||
placeholder="Newsletter"
|
||||
/>
|
||||
</SettingsSection>
|
||||
|
||||
<SettingsSection label="Newsletter Description" htmlFor="newsletter-desc">
|
||||
<Input
|
||||
id="newsletter-desc"
|
||||
value={labels.newsletter_description}
|
||||
onChange={(e) => setLabels({ ...labels, newsletter_description: e.target.value })}
|
||||
placeholder="Subscribe to get updates"
|
||||
/>
|
||||
</SettingsSection>
|
||||
</SettingsCard>
|
||||
|
||||
{/* Contact Data */}
|
||||
<SettingsCard
|
||||
title="Contact Information"
|
||||
description="Manage contact details from Store Identity"
|
||||
>
|
||||
<SettingsSection label="Email" htmlFor="contact-email">
|
||||
<Input
|
||||
id="contact-email"
|
||||
type="email"
|
||||
value={contactData.email}
|
||||
onChange={(e) => setContactData({ ...contactData, email: e.target.value })}
|
||||
placeholder="info@store.com"
|
||||
/>
|
||||
<div className="flex items-center gap-2 mt-2">
|
||||
<Switch
|
||||
checked={contactData.show_email}
|
||||
onCheckedChange={(checked) => setContactData({ ...contactData, show_email: checked })}
|
||||
/>
|
||||
<Label className="text-sm text-muted-foreground">Show in footer</Label>
|
||||
</div>
|
||||
</SettingsSection>
|
||||
|
||||
<SettingsSection label="Phone" htmlFor="contact-phone">
|
||||
<Input
|
||||
id="contact-phone"
|
||||
type="tel"
|
||||
value={contactData.phone}
|
||||
onChange={(e) => setContactData({ ...contactData, phone: e.target.value })}
|
||||
placeholder="(123) 456-7890"
|
||||
/>
|
||||
<div className="flex items-center gap-2 mt-2">
|
||||
<Switch
|
||||
checked={contactData.show_phone}
|
||||
onCheckedChange={(checked) => setContactData({ ...contactData, show_phone: checked })}
|
||||
/>
|
||||
<Label className="text-sm text-muted-foreground">Show in footer</Label>
|
||||
</div>
|
||||
</SettingsSection>
|
||||
|
||||
<SettingsSection label="Address" htmlFor="contact-address">
|
||||
<Textarea
|
||||
id="contact-address"
|
||||
value={contactData.address}
|
||||
onChange={(e) => setContactData({ ...contactData, address: e.target.value })}
|
||||
placeholder="123 Main St, City, State 12345"
|
||||
rows={2}
|
||||
/>
|
||||
<div className="flex items-center gap-2 mt-2">
|
||||
<Switch
|
||||
checked={contactData.show_address}
|
||||
onCheckedChange={(checked) => setContactData({ ...contactData, show_address: checked })}
|
||||
/>
|
||||
<Label className="text-sm text-muted-foreground">Show in footer</Label>
|
||||
</div>
|
||||
</SettingsSection>
|
||||
</SettingsCard>
|
||||
|
||||
{/* Content */}
|
||||
<SettingsCard
|
||||
title="Content"
|
||||
description="Customize footer content"
|
||||
>
|
||||
<SettingsSection label="Copyright Text" htmlFor="copyright">
|
||||
<Textarea
|
||||
id="copyright"
|
||||
value={copyrightText}
|
||||
onChange={(e) => setCopyrightText(e.target.value)}
|
||||
rows={2}
|
||||
placeholder="© 2024 Your Store. All rights reserved."
|
||||
/>
|
||||
</SettingsSection>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label>Social Media Links</Label>
|
||||
<Button onClick={addSocialLink} variant="outline" size="sm">
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
Add Link
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
{socialLinks.map((link) => (
|
||||
<div key={link.id} className="flex gap-2">
|
||||
<Input
|
||||
placeholder="Platform (e.g., Facebook)"
|
||||
value={link.platform}
|
||||
onChange={(e) => updateSocialLink(link.id, 'platform', e.target.value)}
|
||||
className="flex-1"
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h3 className="text-lg font-medium mb-4">Contact Information</h3>
|
||||
<SettingsSection label="Email" htmlFor="contact-email">
|
||||
<Input
|
||||
id="contact-email"
|
||||
type="email"
|
||||
value={contactData.email}
|
||||
onChange={(e) => setContactData({ ...contactData, email: e.target.value })}
|
||||
placeholder="info@store.com"
|
||||
/>
|
||||
<div className="flex items-center gap-2 mt-2">
|
||||
<Switch
|
||||
checked={contactData.show_email}
|
||||
onCheckedChange={(checked) => setContactData({ ...contactData, show_email: checked })}
|
||||
/>
|
||||
<Input
|
||||
placeholder="URL"
|
||||
value={link.url}
|
||||
onChange={(e) => updateSocialLink(link.id, 'url', e.target.value)}
|
||||
className="flex-1"
|
||||
<Label className="text-sm text-muted-foreground">Show in footer</Label>
|
||||
</div>
|
||||
</SettingsSection>
|
||||
|
||||
<SettingsSection label="Phone" htmlFor="contact-phone">
|
||||
<Input
|
||||
id="contact-phone"
|
||||
type="tel"
|
||||
value={contactData.phone}
|
||||
onChange={(e) => setContactData({ ...contactData, phone: e.target.value })}
|
||||
placeholder="(123) 456-7890"
|
||||
/>
|
||||
<div className="flex items-center gap-2 mt-2">
|
||||
<Switch
|
||||
checked={contactData.show_phone}
|
||||
onCheckedChange={(checked) => setContactData({ ...contactData, show_phone: checked })}
|
||||
/>
|
||||
<Button
|
||||
onClick={() => removeSocialLink(link.id)}
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
<Label className="text-sm text-muted-foreground">Show in footer</Label>
|
||||
</div>
|
||||
</SettingsSection>
|
||||
|
||||
<SettingsSection label="Address" htmlFor="contact-address">
|
||||
<Textarea
|
||||
id="contact-address"
|
||||
value={contactData.address}
|
||||
onChange={(e) => setContactData({ ...contactData, address: e.target.value })}
|
||||
placeholder="123 Main St, City, State 12345"
|
||||
rows={2}
|
||||
/>
|
||||
<div className="flex items-center gap-2 mt-2">
|
||||
<Switch
|
||||
checked={contactData.show_address}
|
||||
onCheckedChange={(checked) => setContactData({ ...contactData, show_address: checked })}
|
||||
/>
|
||||
<Label className="text-sm text-muted-foreground">Show in footer</Label>
|
||||
</div>
|
||||
</SettingsSection>
|
||||
</div>
|
||||
|
||||
<div className="border-t pt-6">
|
||||
<h3 className="text-lg font-medium mb-4">General Content</h3>
|
||||
<SettingsSection label="Newsletter Description" htmlFor="newsletter-desc">
|
||||
<Input
|
||||
id="newsletter-desc"
|
||||
value={labels.newsletter_description}
|
||||
onChange={(e) => setLabels({ ...labels, newsletter_description: e.target.value })}
|
||||
placeholder="Subscribe to get updates"
|
||||
/>
|
||||
</SettingsSection>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label>Social Media Links</Label>
|
||||
<Button onClick={addSocialLink} variant="outline" size="sm">
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
Add Link
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
|
||||
<div className="space-y-3">
|
||||
{socialLinks.map((link) => (
|
||||
<div key={link.id} className="flex gap-2">
|
||||
<Input
|
||||
placeholder="Platform (e.g., Facebook)"
|
||||
value={link.platform}
|
||||
onChange={(e) => updateSocialLink(link.id, 'platform', e.target.value)}
|
||||
className="flex-1"
|
||||
/>
|
||||
<Input
|
||||
placeholder="URL"
|
||||
value={link.url}
|
||||
onChange={(e) => updateSocialLink(link.id, 'url', e.target.value)}
|
||||
className="flex-1"
|
||||
/>
|
||||
<Button
|
||||
onClick={() => removeSocialLink(link.id)}
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</SettingsCard>
|
||||
|
||||
{/* Custom Sections Builder */}
|
||||
{/* Custom Columns (was Custom Sections) */}
|
||||
<SettingsCard
|
||||
title="Custom Sections"
|
||||
description="Build custom footer sections with flexible content"
|
||||
title="Custom Columns"
|
||||
description="Build footer columns with flexible content"
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label>Footer Sections</Label>
|
||||
<Label>Footer Columns</Label>
|
||||
<Button onClick={addSection} variant="outline" size="sm">
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
Add Section
|
||||
Add Column
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -405,7 +421,7 @@ export default function AppearanceFooter() {
|
||||
<div key={section.id} className="border rounded-lg p-4 space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<Input
|
||||
placeholder="Section Title"
|
||||
placeholder="Column Title"
|
||||
value={section.title}
|
||||
onChange={(e) => updateSection(section.id, 'title', e.target.value)}
|
||||
className="flex-1 mr-2"
|
||||
@@ -458,11 +474,122 @@ export default function AppearanceFooter() {
|
||||
|
||||
{sections.length === 0 && (
|
||||
<p className="text-sm text-muted-foreground text-center py-4">
|
||||
No custom sections yet. Click "Add Section" to create one.
|
||||
No custom columns yet. Click "Add Column" to create one.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</SettingsCard>
|
||||
|
||||
{/* Payment Methods */}
|
||||
<SettingsCard
|
||||
title="Payment Methods"
|
||||
description="Configure accepted payment methods display"
|
||||
>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="space-y-0.5">
|
||||
<Label>Show Payment Methods</Label>
|
||||
</div>
|
||||
<Switch
|
||||
checked={payment.enabled}
|
||||
onCheckedChange={(checked) => setPayment({ ...payment, enabled: checked })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{payment.enabled && (
|
||||
<div className="space-y-4">
|
||||
<SettingsSection label="Section Title" htmlFor="payment-title">
|
||||
<Input
|
||||
id="payment-title"
|
||||
value={payment.title}
|
||||
onChange={(e) => setPayment({ ...payment, title: e.target.value })}
|
||||
placeholder="We accept"
|
||||
/>
|
||||
</SettingsSection>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label>Payment Logos</Label>
|
||||
<Button onClick={addPaymentMethod} variant="outline" size="sm">
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
Add Method
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-3">
|
||||
{payment.methods.map((method) => (
|
||||
<div key={method.id} className="flex gap-3 items-center border p-3 rounded-lg">
|
||||
<div className="shrink-0">
|
||||
<MediaUploader
|
||||
onSelect={(url) => updatePaymentMethod(method.id, 'url', url)}
|
||||
>
|
||||
{method.url ? (
|
||||
<div className="w-12 h-8 border rounded overflow-hidden relative group cursor-pointer">
|
||||
<img src={method.url} alt={method.label} className="w-full h-full object-contain" />
|
||||
<div className="absolute inset-0 bg-black/40 hidden group-hover:flex items-center justify-center">
|
||||
<Upload className="w-3 h-3 text-white" />
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="w-12 h-8 border rounded bg-muted flex items-center justify-center cursor-pointer hover:bg-muted/80">
|
||||
<Upload className="w-4 h-4 text-muted-foreground" />
|
||||
</div>
|
||||
)}
|
||||
</MediaUploader>
|
||||
</div>
|
||||
<Input
|
||||
placeholder="Label (e.g., Visa)"
|
||||
value={method.label}
|
||||
onChange={(e) => updatePaymentMethod(method.id, 'label', e.target.value)}
|
||||
className="flex-1"
|
||||
/>
|
||||
<Button
|
||||
onClick={() => removePaymentMethod(method.id)}
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="shrink-0 text-destructive hover:text-destructive"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
{payment.methods.length === 0 && (
|
||||
<div className="text-sm text-center py-4 text-muted-foreground bg-muted/20 rounded-lg border border-dashed">
|
||||
No payment methods added.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</SettingsCard>
|
||||
|
||||
{/* Copyright Section */}
|
||||
<SettingsCard
|
||||
title="Copyright"
|
||||
description="Configure copyright notice"
|
||||
>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="space-y-0.5">
|
||||
<Label>Show Copyright</Label>
|
||||
</div>
|
||||
<Switch
|
||||
checked={copyright.enabled}
|
||||
onCheckedChange={(checked) => setCopyright({ ...copyright, enabled: checked })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{copyright.enabled && (
|
||||
<SettingsSection label="Copyright Text" htmlFor="copyright-text">
|
||||
<Textarea
|
||||
id="copyright-text"
|
||||
value={copyright.text}
|
||||
onChange={(e) => setCopyright({ ...copyright, text: e.target.value })}
|
||||
rows={2}
|
||||
placeholder="© 2024 Your Store. All rights reserved."
|
||||
/>
|
||||
</SettingsSection>
|
||||
)}
|
||||
</SettingsCard>
|
||||
</SettingsLayout>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -29,6 +29,7 @@ export default function AppearanceGeneral() {
|
||||
const [customHeading, setCustomHeading] = useState('');
|
||||
const [customBody, setCustomBody] = useState('');
|
||||
const [fontScale, setFontScale] = useState([1.0]);
|
||||
const [containerWidth, setContainerWidth] = useState<'boxed' | 'fullwidth'>('boxed');
|
||||
|
||||
const fontPairs = {
|
||||
modern: { name: 'Modern & Clean', fonts: 'Inter' },
|
||||
@@ -65,6 +66,9 @@ export default function AppearanceGeneral() {
|
||||
setCustomBody(general.typography.custom?.body || '');
|
||||
setFontScale([general.typography.scale || 1.0]);
|
||||
}
|
||||
if (general.container_width) {
|
||||
setContainerWidth(general.container_width);
|
||||
}
|
||||
if (general.colors) {
|
||||
setColors({
|
||||
primary: general.colors.primary || '#1a1a1a',
|
||||
@@ -110,6 +114,7 @@ export default function AppearanceGeneral() {
|
||||
custom: typographyMode === 'custom_google' ? { heading: customHeading, body: customBody } : undefined,
|
||||
scale: fontScale[0],
|
||||
},
|
||||
containerWidth,
|
||||
colors,
|
||||
});
|
||||
|
||||
@@ -207,6 +212,36 @@ export default function AppearanceGeneral() {
|
||||
<strong>Tip:</strong> You can set this page as your homepage in Settings → Reading
|
||||
</p>
|
||||
</SettingsSection>
|
||||
|
||||
<SettingsSection label="Container Width" htmlFor="container-width">
|
||||
<RadioGroup value={containerWidth} onValueChange={(value: any) => setContainerWidth(value)}>
|
||||
<div className="flex items-start space-x-3">
|
||||
<RadioGroupItem value="boxed" id="width-boxed" />
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="width-boxed" className="font-medium cursor-pointer">
|
||||
Boxed
|
||||
</Label>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Content centered with max-width (recommended)
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-start space-x-3">
|
||||
<RadioGroupItem value="fullwidth" id="width-full" />
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="width-full" className="font-medium cursor-pointer">
|
||||
Full Width
|
||||
</Label>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Content fills entire screen width
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</RadioGroup>
|
||||
<p className="text-sm text-muted-foreground mt-2">
|
||||
Default width for all pages (can be overridden per page)
|
||||
</p>
|
||||
</SettingsSection>
|
||||
</div>
|
||||
</SettingsCard>
|
||||
|
||||
|
||||
@@ -52,7 +52,9 @@ interface CanvasRendererProps {
|
||||
onDuplicateSection: (id: string) => void;
|
||||
onMoveSection: (id: string, direction: 'up' | 'down') => void;
|
||||
onReorderSections: (sections: Section[]) => void;
|
||||
|
||||
onDeviceModeChange: (mode: 'desktop' | 'mobile') => void;
|
||||
containerWidth?: 'boxed' | 'fullwidth' | 'default';
|
||||
}
|
||||
|
||||
const SECTION_TYPES = [
|
||||
@@ -84,7 +86,9 @@ export function CanvasRenderer({
|
||||
onDuplicateSection,
|
||||
onMoveSection,
|
||||
onReorderSections,
|
||||
|
||||
onDeviceModeChange,
|
||||
containerWidth = 'default',
|
||||
}: CanvasRendererProps) {
|
||||
const [hoveredSectionId, setHoveredSectionId] = useState<string | null>(null);
|
||||
|
||||
@@ -149,7 +153,9 @@ export function CanvasRenderer({
|
||||
<div
|
||||
className={cn(
|
||||
'mx-auto bg-white shadow-xl rounded-lg transition-all duration-300 min-h-[500px]',
|
||||
deviceMode === 'desktop' ? 'max-w-4xl' : 'max-w-sm'
|
||||
deviceMode === 'mobile' ? 'max-w-sm' : (
|
||||
containerWidth === 'fullwidth' ? 'max-w-full mx-4' : 'max-w-6xl'
|
||||
)
|
||||
)}
|
||||
>
|
||||
{sections.length === 0 ? (
|
||||
|
||||
@@ -173,7 +173,7 @@ export function CanvasSection({
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent className="z-[60]">
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>{__('Delete this section?')}</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
AccordionItem,
|
||||
AccordionTrigger,
|
||||
} from '@/components/ui/accordion';
|
||||
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import { Slider } from '@/components/ui/slider';
|
||||
import {
|
||||
@@ -51,6 +52,7 @@ interface PageItem {
|
||||
title: string;
|
||||
url?: string;
|
||||
isSpaLanding?: boolean;
|
||||
containerWidth?: 'boxed' | 'fullwidth';
|
||||
}
|
||||
|
||||
interface InspectorPanelProps {
|
||||
@@ -69,6 +71,7 @@ interface InspectorPanelProps {
|
||||
onSetAsSpaLanding?: () => void;
|
||||
onUnsetSpaLanding?: () => void;
|
||||
onDeletePage?: () => void;
|
||||
onContainerWidthChange?: (width: 'boxed' | 'fullwidth') => void;
|
||||
}
|
||||
|
||||
// Section field configurations
|
||||
@@ -191,6 +194,7 @@ export function InspectorPanel({
|
||||
onSetAsSpaLanding,
|
||||
onUnsetSpaLanding,
|
||||
onDeletePage,
|
||||
onContainerWidthChange,
|
||||
}: InspectorPanelProps) {
|
||||
if (isCollapsed) {
|
||||
return (
|
||||
@@ -273,6 +277,31 @@ export function InspectorPanel({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Container Width */}
|
||||
{!isTemplate && page && onContainerWidthChange && (
|
||||
<div className="pt-2 border-t mt-2">
|
||||
<Label className="text-xs text-gray-500 uppercase tracking-wider block mb-2">{__('Container Width')}</Label>
|
||||
<RadioGroup
|
||||
value={page.containerWidth || 'boxed'}
|
||||
onValueChange={(val: any) => onContainerWidthChange(val)}
|
||||
className="gap-2"
|
||||
>
|
||||
<div className="flex items-center space-x-2">
|
||||
<RadioGroupItem value="boxed" id="cw-boxed" />
|
||||
<Label htmlFor="cw-boxed" className="text-sm font-normal cursor-pointer">{__('Boxed')}</Label>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<RadioGroupItem value="fullwidth" id="cw-full" />
|
||||
<Label htmlFor="cw-full" className="text-sm font-normal cursor-pointer">{__('Full Width')}</Label>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<RadioGroupItem value="default" id="cw-default" />
|
||||
<Label htmlFor="cw-default" className="text-sm font-normal cursor-pointer text-gray-500">{__('Default (SPA Settings)')}</Label>
|
||||
</div>
|
||||
</RadioGroup>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Danger Zone */}
|
||||
{!isTemplate && page && onDeletePage && (
|
||||
<div className="pt-2 border-t mt-2">
|
||||
|
||||
@@ -93,6 +93,12 @@ export default function AppearancePages() {
|
||||
enabled: !!currentPage,
|
||||
});
|
||||
|
||||
// Fetch global settings for defaults
|
||||
const { data: globalSettings } = useQuery({
|
||||
queryKey: ['appearance-settings'],
|
||||
queryFn: async () => api.get('/appearance/settings'),
|
||||
});
|
||||
|
||||
// Update store when page data loads
|
||||
useEffect(() => {
|
||||
if (pageData?.structure?.sections) {
|
||||
@@ -106,6 +112,10 @@ export default function AppearancePages() {
|
||||
if (pageData?.is_front_page !== undefined && currentPage) {
|
||||
setCurrentPage({ ...currentPage, isFrontPage: !!pageData.is_front_page });
|
||||
}
|
||||
// Sync containerWidth
|
||||
if (pageData?.container_width && currentPage) {
|
||||
setCurrentPage({ ...currentPage, containerWidth: pageData.container_width });
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [pageData, setSections, markAsSaved, setAvailableSources]); // Removed currentPage from dependency to avoid loop
|
||||
|
||||
@@ -296,7 +306,13 @@ export default function AppearancePages() {
|
||||
onDuplicateSection={duplicateSection}
|
||||
onMoveSection={moveSection}
|
||||
onReorderSections={reorderSections}
|
||||
|
||||
onDeviceModeChange={setDeviceMode}
|
||||
containerWidth={
|
||||
currentPage?.containerWidth && currentPage.containerWidth !== 'default'
|
||||
? currentPage.containerWidth
|
||||
: ((globalSettings as any)?.data?.general?.container_width || 'boxed')
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<div className="flex-1 bg-gray-100 flex items-center justify-center text-gray-400">
|
||||
@@ -356,6 +372,12 @@ export default function AppearancePages() {
|
||||
}}
|
||||
onUnsetSpaLanding={() => unsetSpaLandingMutation.mutate()}
|
||||
onDeletePage={handleDeletePage}
|
||||
onContainerWidthChange={(width) => {
|
||||
if (currentPage) {
|
||||
setCurrentPage({ ...currentPage, containerWidth: width });
|
||||
markAsSaved(); // Mark as changed so save button enables
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -62,6 +62,7 @@ export interface PageItem {
|
||||
url?: string;
|
||||
isFrontPage?: boolean;
|
||||
isSpaLanding?: boolean;
|
||||
containerWidth?: 'boxed' | 'fullwidth';
|
||||
}
|
||||
|
||||
interface PageEditorState {
|
||||
@@ -422,7 +423,10 @@ export const usePageEditorStore = create<PageEditorState>((set, get) => ({
|
||||
'X-WP-Nonce': (window as any).WNW_API.nonce,
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({ sections })
|
||||
body: JSON.stringify({
|
||||
sections,
|
||||
container_width: currentPage.containerWidth
|
||||
})
|
||||
});
|
||||
|
||||
set({
|
||||
|
||||
@@ -11,27 +11,34 @@ import { Button } from '@/components/ui/button';
|
||||
import { Card } from '@/components/ui/card';
|
||||
import { ErrorCard } from '@/components/ErrorCard';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { RefreshCw, Trash2, Search, User, ChevronRight, Edit } from 'lucide-react';
|
||||
import { RefreshCw, Trash2, Search, User, ChevronRight, Edit, MoreHorizontal, Eye } from 'lucide-react';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import { formatMoney } from '@/lib/currency';
|
||||
|
||||
export default function CustomersIndex() {
|
||||
const navigate = useNavigate();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
|
||||
// State
|
||||
const [page, setPage] = useState(1);
|
||||
const [search, setSearch] = useState('');
|
||||
const [selectedIds, setSelectedIds] = useState<number[]>([]);
|
||||
|
||||
|
||||
// FAB config - 'none' because submenu has 'New' tab (per SOP)
|
||||
useFABConfig('none');
|
||||
|
||||
|
||||
// Fetch customers
|
||||
const customersQuery = useQuery({
|
||||
queryKey: ['customers', page, search],
|
||||
queryFn: () => CustomersApi.list({ page, per_page: 20, search }),
|
||||
});
|
||||
|
||||
|
||||
// Delete mutation
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: async (ids: number[]) => {
|
||||
@@ -46,14 +53,14 @@ export default function CustomersIndex() {
|
||||
showErrorToast(error);
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
// Handlers
|
||||
const toggleSelection = (id: number) => {
|
||||
setSelectedIds(prev =>
|
||||
prev.includes(id) ? prev.filter(i => i !== id) : [...prev, id]
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
const toggleAll = () => {
|
||||
if (selectedIds.length === customers.length) {
|
||||
setSelectedIds([]);
|
||||
@@ -61,21 +68,21 @@ export default function CustomersIndex() {
|
||||
setSelectedIds(customers.map(c => c.id));
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
const handleDelete = () => {
|
||||
if (selectedIds.length === 0) return;
|
||||
if (!confirm(__('Are you sure you want to delete the selected customers? This action cannot be undone.'))) return;
|
||||
deleteMutation.mutate(selectedIds);
|
||||
};
|
||||
|
||||
|
||||
const handleRefresh = () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['customers'] });
|
||||
};
|
||||
|
||||
|
||||
// Data
|
||||
const customers = customersQuery.data?.data || [];
|
||||
const pagination = customersQuery.data?.pagination;
|
||||
|
||||
|
||||
// Loading state
|
||||
if (customersQuery.isLoading) {
|
||||
return (
|
||||
@@ -85,7 +92,7 @@ export default function CustomersIndex() {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
// Error state
|
||||
if (customersQuery.isError) {
|
||||
return (
|
||||
@@ -96,7 +103,7 @@ export default function CustomersIndex() {
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Mobile: Search */}
|
||||
@@ -130,7 +137,7 @@ export default function CustomersIndex() {
|
||||
{__('Delete')} ({selectedIds.length})
|
||||
</button>
|
||||
)}
|
||||
|
||||
|
||||
<button
|
||||
onClick={handleRefresh}
|
||||
disabled={customersQuery.isFetching}
|
||||
@@ -140,7 +147,7 @@ export default function CustomersIndex() {
|
||||
{__('Refresh')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
|
||||
{/* Right: Search */}
|
||||
<div className="flex gap-3 items-center">
|
||||
<div className="relative">
|
||||
@@ -158,7 +165,7 @@ export default function CustomersIndex() {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
{/* Desktop: Table */}
|
||||
<div className="hidden md:block rounded-lg border overflow-hidden">
|
||||
<table className="w-full">
|
||||
@@ -212,9 +219,8 @@ export default function CustomersIndex() {
|
||||
</td>
|
||||
<td className="p-3 text-sm text-muted-foreground">{customer.email}</td>
|
||||
<td className="p-3">
|
||||
<span className={`inline-flex items-center px-2 py-1 rounded-full text-xs font-medium ${
|
||||
customer.role === 'customer' ? 'bg-blue-100 text-blue-800' : 'bg-gray-100 text-gray-800'
|
||||
}`}>
|
||||
<span className={`inline-flex items-center px-2 py-1 rounded-full text-xs font-medium ${customer.role === 'customer' ? 'bg-blue-100 text-blue-800' : 'bg-gray-100 text-gray-800'
|
||||
}`}>
|
||||
{customer.role === 'customer' ? __('Member') : __('Guest')}
|
||||
</span>
|
||||
</td>
|
||||
@@ -225,14 +231,37 @@ export default function CustomersIndex() {
|
||||
<td className="p-3 text-sm text-muted-foreground">
|
||||
{new Date(customer.registered).toLocaleDateString()}
|
||||
</td>
|
||||
<td className="p-3">
|
||||
<button
|
||||
onClick={() => navigate(`/customers/${customer.id}/edit`)}
|
||||
className="inline-flex items-center gap-1 text-sm text-primary hover:underline"
|
||||
>
|
||||
<Edit className="w-4 h-4" />
|
||||
{__('Edit')}
|
||||
</button>
|
||||
<td className="p-3 text-center">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" className="h-8 w-8 p-0">
|
||||
<span className="sr-only">{__('Open menu')}</span>
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onClick={() => navigate(`/customers/${customer.id}`)}>
|
||||
<Eye className="mr-2 h-4 w-4" />
|
||||
{__('View Details')}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => navigate(`/customers/${customer.id}/edit`)}>
|
||||
<Edit className="mr-2 h-4 w-4" />
|
||||
{__('Edit')}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
className="text-destructive focus:text-destructive"
|
||||
onClick={() => {
|
||||
if (confirm(__('Are you sure you want to delete this customer?'))) {
|
||||
deleteMutation.mutate([customer.id]);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Trash2 className="mr-2 h-4 w-4" />
|
||||
{__('Delete')}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
@@ -240,7 +269,7 @@ export default function CustomersIndex() {
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
|
||||
{/* Mobile: Cards */}
|
||||
<div className="md:hidden space-y-3">
|
||||
{customers.length === 0 ? (
|
||||
@@ -257,7 +286,7 @@ export default function CustomersIndex() {
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
{/* Checkbox */}
|
||||
<div
|
||||
<div
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
@@ -302,7 +331,7 @@ export default function CustomersIndex() {
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
|
||||
{/* Pagination */}
|
||||
{pagination && pagination.total_pages > 1 && (
|
||||
<div className="flex justify-center gap-2">
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { useNavigate, useParams } from 'react-router-dom';
|
||||
import { SettingsLayout } from '@/routes/Settings/components/SettingsLayout';
|
||||
import { SettingsCard } from '@/routes/Settings/components/SettingsCard';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { RichTextEditor } from '@/components/ui/rich-text-editor';
|
||||
import {
|
||||
ArrowLeft,
|
||||
Send,
|
||||
@@ -21,6 +20,7 @@ import { __ } from '@/lib/i18n';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
@@ -181,22 +181,25 @@ export default function CampaignEdit() {
|
||||
|
||||
if (!isNew && isLoading) {
|
||||
return (
|
||||
<SettingsLayout title={__('Loading...')} description="">
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
</SettingsLayout>
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<SettingsLayout
|
||||
title={isNew ? __('New Campaign') : __('Edit Campaign')}
|
||||
description={isNew ? __('Create a new email campaign') : campaign?.title || ''}
|
||||
>
|
||||
{/* Back button */}
|
||||
<div className="mb-6">
|
||||
<Button variant="ghost" onClick={() => navigate('/marketing/campaigns')}>
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-lg font-medium">
|
||||
{isNew ? __('New Campaign') : __('Edit Campaign')}
|
||||
</h2>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{isNew ? __('Create a new email campaign') : campaign?.title || ''}
|
||||
</p>
|
||||
</div>
|
||||
<Button variant="ghost" onClick={() => navigate('/marketing/newsletter/campaigns')}>
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
{__('Back to Campaigns')}
|
||||
</Button>
|
||||
@@ -245,15 +248,14 @@ export default function CampaignEdit() {
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="content">{__('Email Content')}</Label>
|
||||
<Textarea
|
||||
id="content"
|
||||
placeholder={__('Write your newsletter content here...\n\nYou can use:\n- {site_name} - Your store name\n- {current_date} - Today\'s date\n- {subscriber_email} - Subscriber\'s email')}
|
||||
value={content}
|
||||
onChange={(e) => setContent(e.target.value)}
|
||||
className="min-h-[300px] font-mono text-sm"
|
||||
<RichTextEditor
|
||||
content={content}
|
||||
onChange={setContent}
|
||||
placeholder={__('Write your newsletter content here...')}
|
||||
variables={['site_name', 'current_date', 'subscriber_email', 'current_year', 'store_name', 'unsubscribe_url']}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{__('Use HTML for rich formatting. The design wrapper will be applied from your campaign email template.')}
|
||||
{__('Use the toolbar to format text. The design wrapper will be applied from your campaign email template.')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -323,11 +325,13 @@ export default function CampaignEdit() {
|
||||
<DialogContent className="max-w-4xl max-h-[90vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{__('Email Preview')}</DialogTitle>
|
||||
<DialogDescription>{__('Preview how your email will look to subscribers')}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="border rounded-lg bg-white p-4">
|
||||
<div
|
||||
className="prose max-w-none"
|
||||
dangerouslySetInnerHTML={{ __html: previewHtml }}
|
||||
<div className="border rounded-lg overflow-hidden bg-gray-100">
|
||||
<iframe
|
||||
srcDoc={previewHtml}
|
||||
className="w-full min-h-[600px] bg-white"
|
||||
title={__('Email Preview')}
|
||||
/>
|
||||
</div>
|
||||
</DialogContent>
|
||||
@@ -338,8 +342,9 @@ export default function CampaignEdit() {
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{__('Send Test Email')}</DialogTitle>
|
||||
<DialogDescription>{__('Send a test email to verify your campaign before sending to all subscribers')}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-4 p-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="test-email">{__('Email Address')}</Label>
|
||||
<Input
|
||||
@@ -395,6 +400,6 @@ export default function CampaignEdit() {
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</SettingsLayout>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,16 +1,14 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { SettingsLayout } from '@/routes/Settings/components/SettingsLayout';
|
||||
import { SettingsCard } from '@/routes/Settings/components/SettingsCard';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import {
|
||||
Plus,
|
||||
Search,
|
||||
Send,
|
||||
Clock,
|
||||
CheckCircle2,
|
||||
import {
|
||||
Plus,
|
||||
Search,
|
||||
Send,
|
||||
Clock,
|
||||
CheckCircle2,
|
||||
AlertCircle,
|
||||
Trash2,
|
||||
Edit,
|
||||
@@ -44,6 +42,7 @@ import {
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import { SettingsCard } from '@/routes/Settings/components/SettingsCard';
|
||||
|
||||
interface Campaign {
|
||||
id: number;
|
||||
@@ -131,14 +130,18 @@ export default function CampaignsList() {
|
||||
};
|
||||
|
||||
return (
|
||||
<SettingsLayout
|
||||
<SettingsCard
|
||||
title={__('Campaigns')}
|
||||
description={__('Create and send email campaigns to your newsletter subscribers')}
|
||||
>
|
||||
<SettingsCard
|
||||
title={__('All Campaigns')}
|
||||
description={`${campaigns.length} ${__('campaigns total')}`}
|
||||
>
|
||||
<div className="space-y-4">
|
||||
{/* Header with count */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h3 className="text-lg font-medium">{__('All Campaigns')}</h3>
|
||||
<p className="text-sm text-muted-foreground">{campaigns.length} {__('campaigns total')}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
{/* Actions Bar */}
|
||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
|
||||
@@ -151,10 +154,7 @@ export default function CampaignsList() {
|
||||
className="!pl-9"
|
||||
/>
|
||||
</div>
|
||||
<Button onClick={() => navigate('/marketing/campaigns/new')}>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
{__('New Campaign')}
|
||||
</Button>
|
||||
{/* New Campaign button removed - available in sidebar */}
|
||||
</div>
|
||||
|
||||
{/* Campaigns Table */}
|
||||
@@ -168,7 +168,7 @@ export default function CampaignsList() {
|
||||
<div className="space-y-4">
|
||||
<Send className="h-12 w-12 mx-auto opacity-50" />
|
||||
<p>{__('No campaigns yet')}</p>
|
||||
<Button onClick={() => navigate('/marketing/campaigns/new')}>
|
||||
<Button onClick={() => navigate('/marketing/newsletter/campaigns/new')}>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
{__('Create your first campaign')}
|
||||
</Button>
|
||||
@@ -191,7 +191,7 @@ export default function CampaignsList() {
|
||||
{filteredCampaigns.map((campaign) => {
|
||||
const status = statusConfig[campaign.status] || statusConfig.draft;
|
||||
const StatusIcon = status.icon;
|
||||
|
||||
|
||||
return (
|
||||
<TableRow key={campaign.id}>
|
||||
<TableCell>
|
||||
@@ -225,11 +225,11 @@ export default function CampaignsList() {
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="hidden md:table-cell text-muted-foreground">
|
||||
{campaign.sent_at
|
||||
{campaign.sent_at
|
||||
? formatDate(campaign.sent_at)
|
||||
: campaign.scheduled_at
|
||||
? `Scheduled: ${formatDate(campaign.scheduled_at)}`
|
||||
: formatDate(campaign.created_at)
|
||||
? `Scheduled: ${formatDate(campaign.scheduled_at)}`
|
||||
: formatDate(campaign.created_at)
|
||||
}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
@@ -240,7 +240,7 @@ export default function CampaignsList() {
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onClick={() => navigate(`/marketing/campaigns/${campaign.id}`)}>
|
||||
<DropdownMenuItem onClick={() => navigate(`/marketing/newsletter/campaigns/${campaign.id}`)}>
|
||||
<Edit className="mr-2 h-4 w-4" />
|
||||
{__('Edit')}
|
||||
</DropdownMenuItem>
|
||||
@@ -248,7 +248,7 @@ export default function CampaignsList() {
|
||||
<Copy className="mr-2 h-4 w-4" />
|
||||
{__('Duplicate')}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
<DropdownMenuItem
|
||||
onClick={() => setDeleteId(campaign.id)}
|
||||
className="text-red-600"
|
||||
>
|
||||
@@ -266,7 +266,7 @@ export default function CampaignsList() {
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</SettingsCard>
|
||||
</div>
|
||||
|
||||
{/* Delete Confirmation Dialog */}
|
||||
<AlertDialog open={deleteId !== null} onOpenChange={() => setDeleteId(null)}>
|
||||
@@ -288,6 +288,6 @@ export default function CampaignsList() {
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</SettingsLayout>
|
||||
</SettingsCard>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -8,10 +8,18 @@ import { ErrorCard } from '@/components/ErrorCard';
|
||||
import { LoadingState } from '@/components/LoadingState';
|
||||
import { Card } from '@/components/ui/card';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Trash2, RefreshCw, Edit, Tag, Search, SlidersHorizontal } from 'lucide-react';
|
||||
import { Trash2, RefreshCw, Edit, Tag, Search, SlidersHorizontal, MoreHorizontal } from 'lucide-react';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import { useFABConfig } from '@/hooks/useFABConfig';
|
||||
import { CouponFilterSheet } from './components/CouponFilterSheet';
|
||||
import { CouponCard } from './components/CouponCard';
|
||||
@@ -34,11 +42,11 @@ export default function CouponsIndex() {
|
||||
// Fetch coupons
|
||||
const { data, isLoading, isError, error, refetch } = useQuery({
|
||||
queryKey: ['coupons', page, search, discountType],
|
||||
queryFn: () => CouponsApi.list({
|
||||
page,
|
||||
per_page: 20,
|
||||
search,
|
||||
discount_type: discountType && discountType !== 'all' ? discountType : undefined
|
||||
queryFn: () => CouponsApi.list({
|
||||
page,
|
||||
per_page: 20,
|
||||
search,
|
||||
discount_type: discountType && discountType !== 'all' ? discountType : undefined
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -58,7 +66,7 @@ export default function CouponsIndex() {
|
||||
// Bulk delete
|
||||
const handleBulkDelete = async () => {
|
||||
if (!confirm(__('Are you sure you want to delete the selected coupons?'))) return;
|
||||
|
||||
|
||||
for (const id of selectedIds) {
|
||||
await deleteMutation.mutateAsync(id);
|
||||
}
|
||||
@@ -149,7 +157,7 @@ export default function CouponsIndex() {
|
||||
{/* Desktop Toolbar */}
|
||||
<div className="hidden md:block rounded-lg border border-border p-4 bg-card">
|
||||
<div className="flex flex-col lg:flex-row lg:justify-between lg:items-center gap-3">
|
||||
|
||||
|
||||
{/* Left: Bulk Actions */}
|
||||
<div className="flex gap-3">
|
||||
{/* Delete - Show only when items selected */}
|
||||
@@ -173,7 +181,7 @@ export default function CouponsIndex() {
|
||||
<RefreshCw className="w-4 h-4" />
|
||||
{__('Refresh')}
|
||||
</button>
|
||||
|
||||
|
||||
{/* New Coupon - Desktop only */}
|
||||
<button
|
||||
className="border rounded-md px-3 py-2 text-sm bg-primary text-primary-foreground hover:bg-primary/90 inline-flex items-center gap-2"
|
||||
@@ -264,7 +272,7 @@ export default function CouponsIndex() {
|
||||
</td>
|
||||
<td className="p-3">
|
||||
<Link to={`/coupons/${coupon.id}/edit`} className="font-medium hover:underline">
|
||||
{coupon.code}
|
||||
{coupon.code}
|
||||
</Link>
|
||||
{coupon.description && (
|
||||
<div className="text-sm text-muted-foreground line-clamp-1">
|
||||
@@ -289,13 +297,32 @@ export default function CouponsIndex() {
|
||||
)}
|
||||
</td>
|
||||
<td className="p-3 text-center">
|
||||
<button
|
||||
className="inline-flex items-center gap-1 text-sm text-blue-600 hover:text-blue-700"
|
||||
onClick={() => navigate(`/coupons/${coupon.id}/edit`)}
|
||||
>
|
||||
<Edit className="w-4 h-4" />
|
||||
{__('Edit')}
|
||||
</button>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" className="h-8 w-8 p-0">
|
||||
<span className="sr-only">{__('Open menu')}</span>
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onClick={() => navigate(`/coupons/${coupon.id}/edit`)}>
|
||||
<Edit className="mr-2 h-4 w-4" />
|
||||
{__('Edit')}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
className="text-destructive focus:text-destructive"
|
||||
onClick={() => {
|
||||
if (confirm(__('Are you sure you want to delete this coupon?'))) {
|
||||
deleteMutation.mutate(coupon.id);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Trash2 className="mr-2 h-4 w-4" />
|
||||
{__('Delete')}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { SettingsCard } from '@/routes/Settings/components/SettingsCard';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import {
|
||||
@@ -131,138 +130,131 @@ export default function Campaigns() {
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<SettingsCard
|
||||
title={__('All Campaigns')}
|
||||
description={`${campaigns.length} ${__('campaigns total')}`}
|
||||
>
|
||||
<div className="space-y-4">
|
||||
{/* Actions Bar */}
|
||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
|
||||
<div className="relative flex-1 max-w-sm">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground pointer-events-none" />
|
||||
<Input
|
||||
placeholder={__('Search campaigns...')}
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="!pl-9"
|
||||
/>
|
||||
</div>
|
||||
<Button onClick={() => navigate('/marketing/newsletter/campaigns/new')}>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
{__('New Campaign')}
|
||||
</Button>
|
||||
</div>
|
||||
{/* Actions Bar */}
|
||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
|
||||
<div className="relative flex-1 max-w-sm">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground pointer-events-none" />
|
||||
<Input
|
||||
placeholder={__('Search campaigns...')}
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="!pl-9"
|
||||
/>
|
||||
</div>
|
||||
<Button onClick={() => navigate('/marketing/newsletter/campaigns/new')}>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
{__('New Campaign')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Campaigns Table */}
|
||||
{isLoading ? (
|
||||
<div className="text-center py-8 text-muted-foreground">
|
||||
{__('Loading campaigns...')}
|
||||
</div>
|
||||
) : filteredCampaigns.length === 0 ? (
|
||||
<div className="text-center py-12 text-muted-foreground">
|
||||
{searchQuery ? __('No campaigns found matching your search') : (
|
||||
<div className="space-y-4">
|
||||
<Send className="h-12 w-12 mx-auto opacity-50" />
|
||||
<p>{__('No campaigns yet')}</p>
|
||||
<Button onClick={() => navigate('/marketing/newsletter/campaigns/new')}>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
{__('Create your first campaign')}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="border rounded-lg">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>{__('Title')}</TableHead>
|
||||
<TableHead>{__('Status')}</TableHead>
|
||||
<TableHead className="hidden md:table-cell">{__('Recipients')}</TableHead>
|
||||
<TableHead className="hidden md:table-cell">{__('Date')}</TableHead>
|
||||
<TableHead className="text-right">{__('Actions')}</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{filteredCampaigns.map((campaign) => {
|
||||
const status = statusConfig[campaign.status] || statusConfig.draft;
|
||||
const StatusIcon = status.icon;
|
||||
|
||||
return (
|
||||
<TableRow key={campaign.id}>
|
||||
<TableCell>
|
||||
<div>
|
||||
<div className="font-medium">{campaign.title}</div>
|
||||
{campaign.subject && (
|
||||
<div className="text-sm text-muted-foreground truncate max-w-[200px]">
|
||||
{campaign.subject}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<span className={`inline-flex items-center gap-1 px-2 py-1 rounded-full text-xs font-medium ${status.className}`}>
|
||||
<StatusIcon className="h-3 w-3" />
|
||||
{__(status.label)}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell className="hidden md:table-cell">
|
||||
{campaign.status === 'sent' ? (
|
||||
<span>
|
||||
{campaign.sent_count}/{campaign.recipient_count}
|
||||
{campaign.failed_count > 0 && (
|
||||
<span className="text-red-500 ml-1">
|
||||
({campaign.failed_count} {__('failed')})
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
) : (
|
||||
'-'
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="hidden md:table-cell text-muted-foreground">
|
||||
{campaign.sent_at
|
||||
? formatDate(campaign.sent_at)
|
||||
: campaign.scheduled_at
|
||||
? `${__('Scheduled')}: ${formatDate(campaign.scheduled_at)}`
|
||||
: formatDate(campaign.created_at)
|
||||
}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="sm">
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onClick={() => navigate(`/marketing/newsletter/campaigns/${campaign.id}`)}>
|
||||
<Edit className="mr-2 h-4 w-4" />
|
||||
{__('Edit')}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => duplicateMutation.mutate(campaign)}>
|
||||
<Copy className="mr-2 h-4 w-4" />
|
||||
{__('Duplicate')}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => setDeleteId(campaign.id)}
|
||||
className="text-red-600"
|
||||
>
|
||||
<Trash2 className="mr-2 h-4 w-4" />
|
||||
{__('Delete')}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
{/* Campaigns Table */}
|
||||
{isLoading ? (
|
||||
<div className="text-center py-8 text-muted-foreground">
|
||||
{__('Loading campaigns...')}
|
||||
</div>
|
||||
) : filteredCampaigns.length === 0 ? (
|
||||
<div className="text-center py-12 text-muted-foreground">
|
||||
{searchQuery ? __('No campaigns found matching your search') : (
|
||||
<div className="space-y-4">
|
||||
<Send className="h-12 w-12 mx-auto opacity-50" />
|
||||
<p>{__('No campaigns yet')}</p>
|
||||
<Button onClick={() => navigate('/marketing/newsletter/campaigns/new')}>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
{__('Create your first campaign')}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</SettingsCard>
|
||||
) : (
|
||||
<div className="border rounded-lg">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>{__('Title')}</TableHead>
|
||||
<TableHead>{__('Status')}</TableHead>
|
||||
<TableHead className="hidden md:table-cell">{__('Recipients')}</TableHead>
|
||||
<TableHead className="hidden md:table-cell">{__('Date')}</TableHead>
|
||||
<TableHead className="text-right">{__('Actions')}</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{filteredCampaigns.map((campaign) => {
|
||||
const status = statusConfig[campaign.status] || statusConfig.draft;
|
||||
const StatusIcon = status.icon;
|
||||
|
||||
return (
|
||||
<TableRow key={campaign.id}>
|
||||
<TableCell>
|
||||
<div>
|
||||
<div className="font-medium">{campaign.title}</div>
|
||||
{campaign.subject && (
|
||||
<div className="text-sm text-muted-foreground truncate max-w-[200px]">
|
||||
{campaign.subject}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<span className={`inline-flex items-center gap-1 px-2 py-1 rounded-full text-xs font-medium ${status.className}`}>
|
||||
<StatusIcon className="h-3 w-3" />
|
||||
{__(status.label)}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell className="hidden md:table-cell">
|
||||
{campaign.status === 'sent' ? (
|
||||
<span>
|
||||
{campaign.sent_count}/{campaign.recipient_count}
|
||||
{campaign.failed_count > 0 && (
|
||||
<span className="text-red-500 ml-1">
|
||||
({campaign.failed_count} {__('failed')})
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
) : (
|
||||
'-'
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="hidden md:table-cell text-muted-foreground">
|
||||
{campaign.sent_at
|
||||
? formatDate(campaign.sent_at)
|
||||
: campaign.scheduled_at
|
||||
? `${__('Scheduled')}: ${formatDate(campaign.scheduled_at)}`
|
||||
: formatDate(campaign.created_at)
|
||||
}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="sm">
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onClick={() => navigate(`/marketing/newsletter/campaigns/${campaign.id}`)}>
|
||||
<Edit className="mr-2 h-4 w-4" />
|
||||
{__('Edit')}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => duplicateMutation.mutate(campaign)}>
|
||||
<Copy className="mr-2 h-4 w-4" />
|
||||
{__('Duplicate')}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => setDeleteId(campaign.id)}
|
||||
className="text-red-600"
|
||||
>
|
||||
<Trash2 className="mr-2 h-4 w-4" />
|
||||
{__('Delete')}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Delete Confirmation Dialog */}
|
||||
<AlertDialog open={deleteId !== null} onOpenChange={() => setDeleteId(null)}>
|
||||
|
||||
@@ -3,7 +3,8 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { SettingsCard } from '@/routes/Settings/components/SettingsCard';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Download, Trash2, Search } from 'lucide-react';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { Download, Trash2, Search, MoreHorizontal } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import { api } from '@/lib/api';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
@@ -16,6 +17,12 @@ import {
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
|
||||
export default function Subscribers() {
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
@@ -66,91 +73,147 @@ export default function Subscribers() {
|
||||
sub.email.toLowerCase().includes(searchQuery.toLowerCase())
|
||||
);
|
||||
|
||||
// Checkbox logic
|
||||
const [selectedIds, setSelectedIds] = useState<string[]>([]); // Email strings
|
||||
|
||||
const toggleAll = () => {
|
||||
if (selectedIds.length === filteredSubscribers.length) {
|
||||
setSelectedIds([]);
|
||||
} else {
|
||||
setSelectedIds(filteredSubscribers.map((s: any) => s.email));
|
||||
}
|
||||
};
|
||||
|
||||
const toggleRow = (email: string) => {
|
||||
setSelectedIds(prev =>
|
||||
prev.includes(email) ? prev.filter(e => e !== email) : [...prev, email]
|
||||
);
|
||||
};
|
||||
|
||||
const handleBulkDelete = async () => {
|
||||
if (!confirm(__('Are you sure you want to delete selected subscribers?'))) return;
|
||||
|
||||
for (const email of selectedIds) {
|
||||
await deleteSubscriber.mutateAsync(email);
|
||||
}
|
||||
setSelectedIds([]);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<SettingsCard
|
||||
title={__('Subscribers List')}
|
||||
description={`${__('Total subscribers')}: ${subscribersData?.count || 0}`}
|
||||
>
|
||||
<div className="space-y-4">
|
||||
{/* Actions Bar */}
|
||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
|
||||
<div className="relative flex-1 max-w-sm">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground pointer-events-none" />
|
||||
<Input
|
||||
placeholder={__('Filter subscribers...')}
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="!pl-9"
|
||||
/>
|
||||
</div>
|
||||
<Button onClick={exportSubscribers} variant="outline" size="sm">
|
||||
<Download className="mr-2 h-4 w-4" />
|
||||
{__('Export CSV')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Subscribers Table */}
|
||||
{isLoading ? (
|
||||
<div className="text-center py-8 text-muted-foreground">
|
||||
{__('Loading subscribers...')}
|
||||
</div>
|
||||
) : filteredSubscribers.length === 0 ? (
|
||||
<div className="text-center py-8 text-muted-foreground">
|
||||
{searchQuery ? __('No subscribers found matching your search') : __('No subscribers yet')}
|
||||
</div>
|
||||
) : (
|
||||
<div className="border rounded-lg">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>{__('Email')}</TableHead>
|
||||
<TableHead>{__('Status')}</TableHead>
|
||||
<TableHead>{__('Subscribed Date')}</TableHead>
|
||||
<TableHead>{__('WP User')}</TableHead>
|
||||
<TableHead className="text-right">{__('Actions')}</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{filteredSubscribers.map((subscriber: any) => (
|
||||
<TableRow key={subscriber.email}>
|
||||
<TableCell className="font-medium">{subscriber.email}</TableCell>
|
||||
<TableCell>
|
||||
<span className="inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-300">
|
||||
{subscriber.status || __('Active')}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell className="text-muted-foreground">
|
||||
{subscriber.subscribed_at
|
||||
? new Date(subscriber.subscribed_at).toLocaleDateString()
|
||||
: 'N/A'
|
||||
}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{subscriber.user_id ? (
|
||||
<span className="text-xs text-blue-600">{__('Yes')} (ID: {subscriber.user_id})</span>
|
||||
) : (
|
||||
<span className="text-xs text-muted-foreground">{__('No')}</span>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => deleteSubscriber.mutate(subscriber.email)}
|
||||
disabled={deleteSubscriber.isPending}
|
||||
>
|
||||
<Trash2 className="h-4 w-4 text-red-500" />
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
)}
|
||||
{/* Actions Bar */}
|
||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
|
||||
<div className="relative flex-1 max-w-sm">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground pointer-events-none" />
|
||||
<Input
|
||||
placeholder={__('Filter subscribers...')}
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="!pl-9"
|
||||
/>
|
||||
</div>
|
||||
</SettingsCard>
|
||||
<div className="flex gap-2">
|
||||
{selectedIds.length > 0 && (
|
||||
<Button onClick={handleBulkDelete} variant="destructive" size="sm">
|
||||
<Trash2 className="mr-2 h-4 w-4" />
|
||||
{__('Delete')} ({selectedIds.length})
|
||||
</Button>
|
||||
)}
|
||||
<Button onClick={exportSubscribers} variant="outline" size="sm">
|
||||
<Download className="mr-2 h-4 w-4" />
|
||||
{__('Export CSV')}
|
||||
</Button>
|
||||
</div>
|
||||
</div >
|
||||
|
||||
{/* Subscribers Table */}
|
||||
{
|
||||
isLoading ? (
|
||||
<div className="text-center py-8 text-muted-foreground">
|
||||
{__('Loading subscribers...')}
|
||||
</div>
|
||||
) : filteredSubscribers.length === 0 ? (
|
||||
<div className="text-center py-8 text-muted-foreground">
|
||||
{searchQuery ? __('No subscribers found matching your search') : __('No subscribers yet')}
|
||||
</div>
|
||||
) : (
|
||||
<div className="border rounded-lg">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-12 p-3">
|
||||
<Checkbox
|
||||
checked={filteredSubscribers.length > 0 && selectedIds.length === filteredSubscribers.length}
|
||||
onCheckedChange={toggleAll}
|
||||
aria-label={__('Select all')}
|
||||
/>
|
||||
</TableHead>
|
||||
<TableHead>{__('Email')}</TableHead>
|
||||
<TableHead>{__('Status')}</TableHead>
|
||||
<TableHead>{__('Subscribed Date')}</TableHead>
|
||||
<TableHead>{__('WP User')}</TableHead>
|
||||
<TableHead className="text-right">{__('Actions')}</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{filteredSubscribers.map((subscriber: any) => (
|
||||
<TableRow key={subscriber.email}>
|
||||
<TableCell className="p-3">
|
||||
<Checkbox
|
||||
checked={selectedIds.includes(subscriber.email)}
|
||||
onCheckedChange={() => toggleRow(subscriber.email)}
|
||||
aria-label={__('Select subscriber')}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell className="font-medium">{subscriber.email}</TableCell>
|
||||
<TableCell>
|
||||
<span className="inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-300">
|
||||
{subscriber.status || __('Active')}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell className="text-muted-foreground">
|
||||
{subscriber.subscribed_at
|
||||
? new Date(subscriber.subscribed_at).toLocaleDateString()
|
||||
: 'N/A'
|
||||
}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{subscriber.user_id ? (
|
||||
<span className="text-xs text-blue-600">{__('Yes')} (ID: {subscriber.user_id})</span>
|
||||
) : (
|
||||
<span className="text-xs text-muted-foreground">{__('No')}</span>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" className="h-8 w-8 p-0">
|
||||
<span className="sr-only">{__('Open menu')}</span>
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem
|
||||
className="text-destructive focus:text-destructive"
|
||||
onClick={() => {
|
||||
if (confirm(__('Are you sure you want to remove this subscriber?'))) {
|
||||
deleteSubscriber.mutate(subscriber.email);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Trash2 className="mr-2 h-4 w-4" />
|
||||
{__('Remove')}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
{/* Email Template Settings */}
|
||||
<SettingsCard
|
||||
@@ -187,6 +250,6 @@ export default function Subscribers() {
|
||||
</div>
|
||||
</div>
|
||||
</SettingsCard>
|
||||
</div>
|
||||
</div >
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,41 +1,24 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useSearchParams, useNavigate } from 'react-router-dom';
|
||||
import { SettingsLayout } from '@/routes/Settings/components/SettingsLayout';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import React, { useState } from 'react';
|
||||
import { useNavigate, useLocation, Outlet, Link } from 'react-router-dom';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Mail } from 'lucide-react';
|
||||
import { Mail, Users, Send } from 'lucide-react';
|
||||
import { __ } from '@/lib/i18n';
|
||||
import { useModules } from '@/hooks/useModules';
|
||||
import Subscribers from './Subscribers';
|
||||
import Campaigns from './Campaigns';
|
||||
import { cn } from '@/lib/utils'; // Assuming cn exists, widely used in ShadCN
|
||||
|
||||
export default function Newsletter() {
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const [activeTab, setActiveTab] = useState('subscribers');
|
||||
export default function NewsletterLayout() {
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const { isEnabled } = useModules();
|
||||
|
||||
// Check for tab query param
|
||||
useEffect(() => {
|
||||
const tabParam = searchParams.get('tab');
|
||||
if (tabParam && ['subscribers', 'campaigns'].includes(tabParam)) {
|
||||
setActiveTab(tabParam);
|
||||
}
|
||||
}, [searchParams]);
|
||||
|
||||
// Update URL when tab changes
|
||||
const handleTabChange = (value: string) => {
|
||||
setActiveTab(value);
|
||||
setSearchParams({ tab: value });
|
||||
};
|
||||
|
||||
// Show disabled state if newsletter module is off
|
||||
if (!isEnabled('newsletter')) {
|
||||
return (
|
||||
<SettingsLayout
|
||||
title={__('Newsletter')}
|
||||
description={__('Newsletter module is disabled')}
|
||||
>
|
||||
<div className="w-full space-y-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold tracking-tight">{__('Newsletter')}</h1>
|
||||
<p className="text-muted-foreground mt-2">{__('Newsletter module is disabled')}</p>
|
||||
</div>
|
||||
<div className="bg-yellow-50 dark:bg-yellow-950/20 border border-yellow-200 dark:border-yellow-900 rounded-lg p-6 text-center">
|
||||
<Mail className="h-12 w-12 text-yellow-600 mx-auto mb-3" />
|
||||
<h3 className="font-semibold text-lg mb-2">{__('Newsletter Module Disabled')}</h3>
|
||||
@@ -46,29 +29,78 @@ export default function Newsletter() {
|
||||
{__('Go to Module Settings')}
|
||||
</Button>
|
||||
</div>
|
||||
</SettingsLayout>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const navItems = [
|
||||
{
|
||||
id: 'subscribers',
|
||||
label: __('Subscribers'),
|
||||
icon: Users,
|
||||
path: '/marketing/newsletter/subscribers',
|
||||
isActive: (path: string) => path.includes('/subscribers')
|
||||
},
|
||||
{
|
||||
id: 'campaigns',
|
||||
label: __('Campaigns'),
|
||||
icon: Send,
|
||||
path: '/marketing/newsletter/campaigns',
|
||||
isActive: (path: string) => path.includes('/campaigns')
|
||||
}
|
||||
];
|
||||
|
||||
return (
|
||||
<SettingsLayout
|
||||
title={__('Newsletter')}
|
||||
description={__('Manage subscribers and send email campaigns')}
|
||||
>
|
||||
<Tabs value={activeTab} onValueChange={handleTabChange} className="space-y-6">
|
||||
<TabsList className="grid w-full max-w-md grid-cols-2">
|
||||
<TabsTrigger value="subscribers">{__('Subscribers')}</TabsTrigger>
|
||||
<TabsTrigger value="campaigns">{__('Campaigns')}</TabsTrigger>
|
||||
</TabsList>
|
||||
<div className="w-full space-y-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold tracking-tight">{__('Newsletter')}</h1>
|
||||
<p className="text-muted-foreground mt-2">{__('Manage subscribers and send email campaigns')}</p>
|
||||
</div>
|
||||
|
||||
<TabsContent value="subscribers" className="space-y-4 mt-6">
|
||||
<Subscribers />
|
||||
</TabsContent>
|
||||
<div className="flex flex-col lg:flex-row gap-6">
|
||||
{/* Sidebar Navigation */}
|
||||
<div className="w-full lg:w-56 flex-shrink-0 space-y-4">
|
||||
<nav className="space-y-1">
|
||||
{navItems.map((item) => {
|
||||
const Icon = item.icon;
|
||||
const active = item.isActive(location.pathname);
|
||||
return (
|
||||
<Link
|
||||
key={item.id}
|
||||
to={item.path}
|
||||
className={cn(
|
||||
'w-full text-left px-4 py-2.5 rounded-md text-sm font-medium transition-colors flex items-center gap-3',
|
||||
// Focus styles matching ShadCN buttons (ring only on keyboard focus)
|
||||
'outline-none ring-offset-background focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2',
|
||||
active
|
||||
? 'bg-primary text-primary-foreground hover:bg-primary/90'
|
||||
: 'text-muted-foreground hover:bg-muted hover:text-foreground active:bg-muted active:text-foreground focus:bg-muted focus:text-foreground'
|
||||
)}
|
||||
>
|
||||
<Icon className="w-4 h-4" />
|
||||
{item.label}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
|
||||
<TabsContent value="campaigns" className="space-y-4 mt-6">
|
||||
<Campaigns />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</SettingsLayout>
|
||||
<div className="pt-4 border-t">
|
||||
<Button
|
||||
className="w-full justify-start"
|
||||
variant="outline"
|
||||
onClick={() => navigate('/marketing/newsletter/campaigns/new')}
|
||||
>
|
||||
<span className="mr-2">+</span>
|
||||
{__('New Campaign')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content Area */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<Outlet />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { SettingsLayout } from '@/routes/Settings/components/SettingsLayout';
|
||||
import { Mail, Tag } from 'lucide-react';
|
||||
import { __ } from '@/lib/i18n';
|
||||
|
||||
@@ -29,10 +28,12 @@ export default function Marketing() {
|
||||
const navigate = useNavigate();
|
||||
|
||||
return (
|
||||
<SettingsLayout
|
||||
title={__('Marketing')}
|
||||
description={__('Newsletter, campaigns, and promotions')}
|
||||
>
|
||||
<div className="w-full space-y-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold tracking-tight">{__('Marketing')}</h1>
|
||||
<p className="text-muted-foreground mt-2">{__('Newsletter, campaigns, and promotions')}</p>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{cards.map((card) => (
|
||||
<button
|
||||
@@ -52,6 +53,6 @@ export default function Marketing() {
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</SettingsLayout>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -39,7 +39,7 @@ function StatusBadge({ status }: { status?: string }) {
|
||||
return <span className={`${cls} ${tone}`}>{status ? status[0].toUpperCase() + status.slice(1) : '—'}</span>;
|
||||
}
|
||||
|
||||
const STATUS_OPTIONS = ['pending', 'processing', 'completed', 'on-hold', 'cancelled', 'refunded', 'failed'];
|
||||
const STATUS_OPTIONS = ['pending', 'processing', 'completed', 'on-hold', 'cancelled', 'refunded', 'failed', 'draft'];
|
||||
|
||||
export default function OrderShow() {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
@@ -315,6 +315,69 @@ export default function OrderShow() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Related Items (Subscription & Licenses) */}
|
||||
{(order.related_subscription || (order.related_licenses && order.related_licenses.length > 0)) && (
|
||||
<div className="rounded border overflow-hidden">
|
||||
<div className="px-4 py-3 border-b font-medium">{__('Related Items')}</div>
|
||||
<div className="p-4 space-y-4">
|
||||
{/* Related Subscription */}
|
||||
{order.related_subscription && (
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<div className="text-sm font-medium flex items-center gap-2">
|
||||
<RefreshCw className="w-4 h-4 text-muted-foreground" />
|
||||
{__('Subscription')}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground mt-1">
|
||||
{order.related_subscription.billing_schedule} • <span className="capitalize">{order.related_subscription.status}</span>
|
||||
</div>
|
||||
</div>
|
||||
<Link to={`/subscriptions/${order.related_subscription.id}`}>
|
||||
<Button variant="outline" size="sm" className="h-8">
|
||||
#{order.related_subscription.id}
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Separator if both exist */}
|
||||
{order.related_subscription && order.related_licenses && order.related_licenses.length > 0 && (
|
||||
<div className="border-t"></div>
|
||||
)}
|
||||
|
||||
{/* Related Licenses */}
|
||||
{order.related_licenses && order.related_licenses.length > 0 && (
|
||||
<div className="space-y-3">
|
||||
{order.related_licenses.map((lic: any) => (
|
||||
<div key={lic.id} className="flex items-start justify-between gap-4">
|
||||
<div className="min-w-0">
|
||||
<div className="text-sm font-medium flex items-center gap-2">
|
||||
<Ticket className="w-4 h-4 text-muted-foreground" />
|
||||
{__('License Key')}
|
||||
</div>
|
||||
<div className="text-xs font-mono bg-gray-100 px-1.5 py-0.5 rounded mt-1.5 inline-block break-all select-all">
|
||||
{lic.license_key}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground mt-1 truncate">
|
||||
{lic.product_name}
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<span className={`inline-flex items-center rounded px-1.5 py-0.5 text-[10px] font-medium border uppercase ${lic.status === 'active' ? 'bg-green-100 text-green-800 border-green-200' :
|
||||
lic.status === 'expired' ? 'bg-red-100 text-red-800 border-red-200' :
|
||||
'bg-gray-100 text-gray-700 border-gray-200'
|
||||
}`}>
|
||||
{lic.status}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Items */}
|
||||
<div className="rounded border overflow-hidden">
|
||||
<div className="px-4 py-3 border-b font-medium">{__('Items')}</div>
|
||||
|
||||
@@ -1,24 +1,31 @@
|
||||
import React, { useState, useCallback } from 'react';
|
||||
import { useQuery, useMutation, keepPreviousData } from '@tanstack/react-query';
|
||||
import { api } from '@/lib/api';
|
||||
import { Filter, PackageOpen, Trash2, RefreshCw } from 'lucide-react';
|
||||
import { Filter, PackageOpen, Trash2, RefreshCw, MoreHorizontal, Eye, Edit } from 'lucide-react';
|
||||
import { ErrorCard } from '@/components/ErrorCard';
|
||||
import { getPageLoadErrorMessage } from '@/lib/errorHandling';
|
||||
import { __ } from '@/lib/i18n';
|
||||
import { useFABConfig } from '@/hooks/useFABConfig';
|
||||
import { HoverCard, HoverCardContent, HoverCardTrigger } from '@/components/ui/hover-card';
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle
|
||||
} from '@/components/ui/alert-dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import { toast } from 'sonner';
|
||||
import {
|
||||
Select,
|
||||
@@ -94,8 +101,8 @@ export default function Orders() {
|
||||
const [status, setStatus] = useState<string | undefined>(initial.status || undefined);
|
||||
const [dateStart, setDateStart] = useState<string | undefined>(initial.date_start || undefined);
|
||||
const [dateEnd, setDateEnd] = useState<string | undefined>(initial.date_end || undefined);
|
||||
const [orderby, setOrderby] = useState<'date'|'id'|'modified'|'total'>((initial.orderby as any) || 'date');
|
||||
const [order, setOrder] = useState<'asc'|'desc'>((initial.order as any) || 'desc');
|
||||
const [orderby, setOrderby] = useState<'date' | 'id' | 'modified' | 'total'>((initial.orderby as any) || 'date');
|
||||
const [order, setOrder] = useState<'asc' | 'desc'>((initial.order as any) || 'desc');
|
||||
const [selectedIds, setSelectedIds] = useState<number[]>([]);
|
||||
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
|
||||
const [filterSheetOpen, setFilterSheetOpen] = useState(false);
|
||||
@@ -136,7 +143,7 @@ export default function Orders() {
|
||||
const rows = data?.rows;
|
||||
if (!rows) return [];
|
||||
if (!searchQuery.trim()) return rows;
|
||||
|
||||
|
||||
const query = searchQuery.toLowerCase();
|
||||
return rows.filter((order: any) =>
|
||||
order.number?.toString().includes(query) ||
|
||||
@@ -255,8 +262,8 @@ export default function Orders() {
|
||||
{__('Delete')} ({selectedIds.length})
|
||||
</button>
|
||||
)}
|
||||
|
||||
<button
|
||||
|
||||
<button
|
||||
className="border rounded-md px-3 py-2 text-sm hover:bg-accent disabled:opacity-50 inline-flex items-center gap-2"
|
||||
onClick={handleRefresh}
|
||||
disabled={q.isLoading || isRefreshing}
|
||||
@@ -305,7 +312,7 @@ export default function Orders() {
|
||||
setOrder((v.order ?? 'desc') as 'asc' | 'desc');
|
||||
}}
|
||||
/>
|
||||
|
||||
|
||||
{activeFiltersCount > 0 && (
|
||||
<button
|
||||
className="text-sm text-muted-foreground hover:text-foreground underline text-nowrap"
|
||||
@@ -432,7 +439,7 @@ export default function Orders() {
|
||||
/>
|
||||
</td>
|
||||
<td className="p-3">
|
||||
<Link className="underline underline-offset-2" to={`/orders/${row.id}`}>#{row.number}</Link>
|
||||
<Link className="font-medium hover:underline" to={`/orders/${row.id}`}>#{row.number}</Link>
|
||||
</td>
|
||||
<td className="p-3 min-w-32">
|
||||
<span title={row.date ?? ""}>
|
||||
@@ -454,9 +461,36 @@ export default function Orders() {
|
||||
decimals: store.decimals,
|
||||
})}
|
||||
</td>
|
||||
<td className="p-3 text-center space-x-2">
|
||||
<Link className="btn text-sm underline underline-offset-2" to={`/orders/${row.id}`}>{__('Open')}</Link>
|
||||
<Link className="btn text-sm underline underline-offset-2" to={`/orders/${row.id}/edit`}>{__('Edit')}</Link>
|
||||
<td className="p-3 text-center">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" className="h-8 w-8 p-0">
|
||||
<span className="sr-only">{__('Open menu')}</span>
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onClick={() => nav(`/orders/${row.id}`)}>
|
||||
<Eye className="mr-2 h-4 w-4" />
|
||||
{__('View Details')}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => nav(`/orders/${row.id}/edit`)}>
|
||||
<Edit className="mr-2 h-4 w-4" />
|
||||
{__('Edit Order')}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
className="text-destructive focus:text-destructive"
|
||||
onClick={() => {
|
||||
setSelectedIds([row.id]);
|
||||
setShowDeleteDialog(true);
|
||||
}}
|
||||
>
|
||||
<Trash2 className="mr-2 h-4 w-4" />
|
||||
{__('Delete')}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
|
||||
@@ -1,20 +1,20 @@
|
||||
import React, { useState, useCallback } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient, keepPreviousData } from '@tanstack/react-query';
|
||||
import { api } from '@/lib/api';
|
||||
import { Filter, Package, Trash2, RefreshCw } from 'lucide-react';
|
||||
import { Filter, Package, Trash2, RefreshCw, MoreHorizontal, Eye, Edit } from 'lucide-react';
|
||||
import { ErrorCard } from '@/components/ErrorCard';
|
||||
import { getPageLoadErrorMessage } from '@/lib/errorHandling';
|
||||
import { __ } from '@/lib/i18n';
|
||||
import { useFABConfig } from '@/hooks/useFABConfig';
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle
|
||||
} from '@/components/ui/alert-dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
@@ -27,6 +27,13 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import { Link, useNavigate } from 'react-router-dom';
|
||||
import { formatMoney, getStoreCurrency } from '@/lib/currency';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
@@ -45,7 +52,7 @@ function StockBadge({ value, quantity }: { value?: string; quantity?: number })
|
||||
const v = (value || '').toLowerCase();
|
||||
const cls = stockStatusStyle[v] || 'bg-slate-100 text-slate-800';
|
||||
const label = v === 'instock' ? __('In Stock') : v === 'outofstock' ? __('Out of Stock') : v === 'onbackorder' ? __('On Backorder') : v;
|
||||
|
||||
|
||||
return (
|
||||
<span className={`inline-flex items-center px-2 py-0.5 rounded text-xs font-medium ${cls}`}>
|
||||
{label}
|
||||
@@ -62,8 +69,8 @@ export default function Products() {
|
||||
const [type, setType] = useState<string | undefined>(initial.type || undefined);
|
||||
const [stockStatus, setStockStatus] = useState<string | undefined>(initial.stock_status || undefined);
|
||||
const [category, setCategory] = useState<string | undefined>(initial.category || undefined);
|
||||
const [orderby, setOrderby] = useState<'date'|'title'|'id'|'modified'>((initial.orderby as any) || 'date');
|
||||
const [order, setOrder] = useState<'asc'|'desc'>((initial.order as any) || 'desc');
|
||||
const [orderby, setOrderby] = useState<'date' | 'title' | 'id' | 'modified'>((initial.orderby as any) || 'date');
|
||||
const [order, setOrder] = useState<'asc' | 'desc'>((initial.order as any) || 'desc');
|
||||
const [selectedIds, setSelectedIds] = useState<number[]>([]);
|
||||
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
|
||||
const [filterSheetOpen, setFilterSheetOpen] = useState(false);
|
||||
@@ -113,7 +120,7 @@ export default function Products() {
|
||||
const rows = data?.rows;
|
||||
if (!rows) return [];
|
||||
if (!searchQuery.trim()) return rows;
|
||||
|
||||
|
||||
const query = searchQuery.toLowerCase();
|
||||
return rows.filter((product: any) =>
|
||||
product.name?.toLowerCase().includes(query) ||
|
||||
@@ -227,7 +234,7 @@ export default function Products() {
|
||||
<div className="flex flex-col lg:flex-row lg:justify-between lg:items-center gap-3">
|
||||
<div className="flex gap-3">
|
||||
{selectedIds.length > 0 && (
|
||||
<button
|
||||
<button
|
||||
className="border rounded-md px-3 py-2 text-sm bg-red-600 text-white hover:bg-red-700 disabled:opacity-50 inline-flex items-center gap-2"
|
||||
onClick={handleDeleteClick}
|
||||
disabled={deleteMutation.isPending}
|
||||
@@ -236,8 +243,8 @@ export default function Products() {
|
||||
{__('Delete')} ({selectedIds.length})
|
||||
</button>
|
||||
)}
|
||||
|
||||
<button
|
||||
|
||||
<button
|
||||
className="border rounded-md px-3 py-2 text-sm hover:bg-accent disabled:opacity-50 inline-flex items-center gap-2"
|
||||
onClick={handleRefresh}
|
||||
disabled={q.isLoading || isRefreshing}
|
||||
@@ -412,9 +419,37 @@ export default function Products() {
|
||||
</span>
|
||||
</td>
|
||||
<td className="p-3 text-right">
|
||||
<Link to={`/products/${product.id}/edit`} className="text-sm text-primary hover:underline">
|
||||
{__('Edit')}
|
||||
</Link>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" className="h-8 w-8 p-0">
|
||||
<span className="sr-only">{__('Open menu')}</span>
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onClick={() => nav(`/products/${product.id}/edit`)}>
|
||||
<Edit className="mr-2 h-4 w-4" />
|
||||
{__('Edit')}
|
||||
</DropdownMenuItem>
|
||||
{product.permalink && (
|
||||
<DropdownMenuItem onClick={() => window.open(product.permalink, '_blank')}>
|
||||
<Eye className="mr-2 h-4 w-4" />
|
||||
{__('View')}
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
className="text-destructive focus:text-destructive"
|
||||
onClick={() => {
|
||||
setSelectedIds([product.id]);
|
||||
setShowDeleteDialog(true);
|
||||
}}
|
||||
>
|
||||
<Trash2 className="mr-2 h-4 w-4" />
|
||||
{__('Delete')}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
|
||||
@@ -218,7 +218,7 @@ export default function EditTemplate() {
|
||||
// Replace store-identity variables with actual data
|
||||
const storeVariables: { [key: string]: string } = {
|
||||
store_name: 'My WordPress Store',
|
||||
store_url: window.location.origin,
|
||||
site_url: window.location.origin,
|
||||
store_email: 'store@example.com',
|
||||
};
|
||||
|
||||
@@ -298,7 +298,7 @@ export default function EditTemplate() {
|
||||
current_year: new Date().getFullYear().toString(),
|
||||
site_name: 'My WordPress Store',
|
||||
store_name: 'My WordPress Store',
|
||||
store_url: '#',
|
||||
site_url: '#',
|
||||
store_email: 'store@example.com',
|
||||
support_email: 'support@example.com',
|
||||
// Account-related URLs and variables
|
||||
@@ -310,6 +310,9 @@ export default function EditTemplate() {
|
||||
user_temp_password: '••••••••',
|
||||
customer_first_name: 'John',
|
||||
customer_last_name: 'Doe',
|
||||
// Campaign/Newsletter variables
|
||||
content: '<p>This is sample content that would be replaced with your actual campaign content.</p>',
|
||||
campaign_title: 'Newsletter Campaign',
|
||||
};
|
||||
|
||||
Object.keys(sampleData).forEach((key) => {
|
||||
@@ -393,6 +396,7 @@ export default function EditTemplate() {
|
||||
p { font-size: 16px; line-height: 1.6; color: #555; margin-bottom: 16px; }
|
||||
.button { display: inline-block; background: ${primaryColor}; color: ${buttonTextColor} !important; padding: 14px 28px; border-radius: 6px; text-decoration: none; font-weight: 600; }
|
||||
.button-outline { display: inline-block; background: transparent; color: ${secondaryColor} !important; padding: 12px 26px; border: 2px solid ${secondaryColor}; border-radius: 6px; text-decoration: none; font-weight: 600; }
|
||||
.text-link { color: ${primaryColor}; text-decoration: underline; }
|
||||
.info-box { background: #f6f6f6; border-radius: 6px; padding: 20px; margin: 16px 0; }
|
||||
.footer { padding: 32px; text-align: center; color: #888; font-size: 13px; }
|
||||
</style>
|
||||
@@ -597,7 +601,7 @@ export default function EditTemplate() {
|
||||
{__('Send a test email with sample data to verify the template looks correct.')}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4 py-4">
|
||||
<div className="space-y-4 p-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="test-email">{__('Email Address')}</Label>
|
||||
<Input
|
||||
|
||||
@@ -124,7 +124,7 @@ export default function TemplateEditor({
|
||||
// Replace store-identity variables with actual data
|
||||
const storeVariables: { [key: string]: string } = {
|
||||
store_name: 'My WordPress Store',
|
||||
store_url: window.location.origin,
|
||||
site_url: window.location.origin,
|
||||
store_email: 'store@example.com',
|
||||
};
|
||||
|
||||
|
||||
298
admin-spa/src/routes/Settings/Security.tsx
Normal file
298
admin-spa/src/routes/Settings/Security.tsx
Normal file
@@ -0,0 +1,298 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { __ } from '@/lib/i18n';
|
||||
import { Shield, AlertTriangle, ExternalLink } from 'lucide-react';
|
||||
import { SettingsLayout } from './components/SettingsLayout';
|
||||
import { SettingsCard } from './components/SettingsCard';
|
||||
import { ToggleField } from './components/ToggleField';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group';
|
||||
|
||||
interface SecuritySettings {
|
||||
enable_checkout_rate_limit: boolean;
|
||||
rate_limit_orders: number;
|
||||
rate_limit_minutes: number;
|
||||
captcha_provider: 'none' | 'recaptcha' | 'turnstile';
|
||||
recaptcha_site_key: string;
|
||||
recaptcha_secret_key: string;
|
||||
turnstile_site_key: string;
|
||||
turnstile_secret_key: string;
|
||||
}
|
||||
|
||||
export default function SecuritySettings() {
|
||||
const [settings, setSettings] = useState<SecuritySettings>({
|
||||
enable_checkout_rate_limit: true,
|
||||
rate_limit_orders: 5,
|
||||
rate_limit_minutes: 10,
|
||||
captcha_provider: 'none',
|
||||
recaptcha_site_key: '',
|
||||
recaptcha_secret_key: '',
|
||||
turnstile_site_key: '',
|
||||
turnstile_secret_key: '',
|
||||
});
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [message, setMessage] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
fetchSettings();
|
||||
}, []);
|
||||
|
||||
const fetchSettings = async () => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
const response = await fetch(
|
||||
`${(window as any).WNW_CONFIG?.restUrl || ''}/store/security-settings`,
|
||||
{
|
||||
credentials: 'include',
|
||||
headers: {
|
||||
'X-WP-Nonce': (window as any).WNW_CONFIG?.nonce || (window as any).wpApiSettings?.nonce || '',
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
if (!response.ok) throw new Error('Failed to fetch');
|
||||
const data = await response.json();
|
||||
setSettings(data);
|
||||
} catch (error) {
|
||||
console.error('Error:', error);
|
||||
setMessage('Failed to load settings');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
try {
|
||||
setIsSaving(true);
|
||||
setMessage('');
|
||||
const response = await fetch(
|
||||
`${(window as any).WNW_CONFIG?.restUrl || ''}/store/security-settings`,
|
||||
{
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-WP-Nonce': (window as any).WNW_CONFIG?.nonce || (window as any).wpApiSettings?.nonce || '',
|
||||
},
|
||||
body: JSON.stringify(settings),
|
||||
}
|
||||
);
|
||||
|
||||
if (!response.ok) throw new Error('Failed to save');
|
||||
const data = await response.json();
|
||||
setMessage(data.message || 'Settings saved successfully');
|
||||
if (data.settings) setSettings(data.settings);
|
||||
} catch (error) {
|
||||
console.error('Error:', error);
|
||||
setMessage('Failed to save settings');
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<SettingsLayout
|
||||
title={__('Security Settings')}
|
||||
description={__('Configure checkout security and spam protection')}
|
||||
isLoading={true}
|
||||
>
|
||||
<div className="animate-pulse h-64 bg-muted rounded-lg"></div>
|
||||
</SettingsLayout>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<SettingsLayout
|
||||
title={__('Security Settings')}
|
||||
description={__('Configure checkout security and spam protection')}
|
||||
onSave={handleSave}
|
||||
saveLabel={__('Save Changes')}
|
||||
>
|
||||
{message && (
|
||||
<div className={`p-4 rounded-lg ${message.includes('success') ? 'bg-green-50 text-green-900' : 'bg-red-50 text-red-900'}`}>
|
||||
{message}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Rate Limiting */}
|
||||
<SettingsCard
|
||||
title={__('Rate Limiting')}
|
||||
description={__('Prevent order bombing by limiting orders per IP')}
|
||||
>
|
||||
<div className="space-y-6">
|
||||
<ToggleField
|
||||
id="enable_checkout_rate_limit"
|
||||
label={__('Enable checkout rate limiting')}
|
||||
description={__('Limit the number of orders that can be placed from a single IP address within a time window.')}
|
||||
checked={settings.enable_checkout_rate_limit}
|
||||
onCheckedChange={(checked) => setSettings({ ...settings, enable_checkout_rate_limit: checked })}
|
||||
/>
|
||||
|
||||
{settings.enable_checkout_rate_limit && (
|
||||
<div className="grid grid-cols-2 gap-4 pl-6 border-l-2 border-muted">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="rate_limit_orders">{__('Maximum orders')}</Label>
|
||||
<Input
|
||||
id="rate_limit_orders"
|
||||
type="number"
|
||||
min={1}
|
||||
max={100}
|
||||
value={settings.rate_limit_orders}
|
||||
onChange={(e) => setSettings({ ...settings, rate_limit_orders: parseInt(e.target.value) || 5 })}
|
||||
/>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{__('Number of orders allowed per IP')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="rate_limit_minutes">{__('Time window (minutes)')}</Label>
|
||||
<Input
|
||||
id="rate_limit_minutes"
|
||||
type="number"
|
||||
min={1}
|
||||
max={1440}
|
||||
value={settings.rate_limit_minutes}
|
||||
onChange={(e) => setSettings({ ...settings, rate_limit_minutes: parseInt(e.target.value) || 10 })}
|
||||
/>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{__('Reset period in minutes')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</SettingsCard>
|
||||
|
||||
{/* CAPTCHA */}
|
||||
<SettingsCard
|
||||
title={__('CAPTCHA Protection')}
|
||||
description={__('Add invisible bot protection to checkout')}
|
||||
>
|
||||
<div className="space-y-6">
|
||||
<div className="flex gap-3 p-4 bg-amber-50 dark:bg-amber-950/20 border border-amber-200 dark:border-amber-900 rounded-lg">
|
||||
<AlertTriangle className="w-5 h-5 text-amber-600 flex-shrink-0 mt-0.5" />
|
||||
<div className="text-sm text-amber-900 dark:text-amber-100">
|
||||
<p className="font-medium mb-1">{__('Invisible CAPTCHA')}</p>
|
||||
<p className="text-amber-700 dark:text-amber-300">
|
||||
{__('Both options use invisible verification - no user interaction required. They detect bots automatically in the background.')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<RadioGroup
|
||||
value={settings.captcha_provider}
|
||||
onValueChange={(value: SecuritySettings['captcha_provider']) =>
|
||||
setSettings({ ...settings, captcha_provider: value })
|
||||
}
|
||||
className="space-y-4"
|
||||
>
|
||||
<div className="flex items-start space-x-3">
|
||||
<RadioGroupItem value="none" id="captcha_none" className="mt-1" />
|
||||
<div>
|
||||
<Label htmlFor="captcha_none" className="font-medium cursor-pointer">
|
||||
{__('None')}
|
||||
</Label>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{__('No CAPTCHA verification')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-start space-x-3">
|
||||
<RadioGroupItem value="recaptcha" id="captcha_recaptcha" className="mt-1" />
|
||||
<div className="flex-1">
|
||||
<Label htmlFor="captcha_recaptcha" className="font-medium cursor-pointer">
|
||||
{__('Google reCAPTCHA v3')}
|
||||
</Label>
|
||||
<p className="text-sm text-muted-foreground mb-3">
|
||||
{__('Invisible verification by Google')}
|
||||
<a
|
||||
href="https://www.google.com/recaptcha/admin/create"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="ml-2 text-primary hover:underline inline-flex items-center gap-1"
|
||||
>
|
||||
{__('Get API keys')} <ExternalLink className="w-3 h-3" />
|
||||
</a>
|
||||
</p>
|
||||
|
||||
{settings.captcha_provider === 'recaptcha' && (
|
||||
<div className="space-y-4 pt-2">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="recaptcha_site_key">{__('Site Key')}</Label>
|
||||
<Input
|
||||
id="recaptcha_site_key"
|
||||
type="text"
|
||||
placeholder="6Le..."
|
||||
value={settings.recaptcha_site_key}
|
||||
onChange={(e) => setSettings({ ...settings, recaptcha_site_key: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="recaptcha_secret_key">{__('Secret Key')}</Label>
|
||||
<Input
|
||||
id="recaptcha_secret_key"
|
||||
type="password"
|
||||
placeholder="6Le..."
|
||||
value={settings.recaptcha_secret_key}
|
||||
onChange={(e) => setSettings({ ...settings, recaptcha_secret_key: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-start space-x-3">
|
||||
<RadioGroupItem value="turnstile" id="captcha_turnstile" className="mt-1" />
|
||||
<div className="flex-1">
|
||||
<Label htmlFor="captcha_turnstile" className="font-medium cursor-pointer">
|
||||
{__('Cloudflare Turnstile')}
|
||||
</Label>
|
||||
<p className="text-sm text-muted-foreground mb-3">
|
||||
{__('Privacy-focused invisible verification by Cloudflare')}
|
||||
<a
|
||||
href="https://dash.cloudflare.com/?to=/:account/turnstile"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="ml-2 text-primary hover:underline inline-flex items-center gap-1"
|
||||
>
|
||||
{__('Get API keys')} <ExternalLink className="w-3 h-3" />
|
||||
</a>
|
||||
</p>
|
||||
|
||||
{settings.captcha_provider === 'turnstile' && (
|
||||
<div className="space-y-4 pt-2">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="turnstile_site_key">{__('Site Key')}</Label>
|
||||
<Input
|
||||
id="turnstile_site_key"
|
||||
type="text"
|
||||
placeholder="0x..."
|
||||
value={settings.turnstile_site_key}
|
||||
onChange={(e) => setSettings({ ...settings, turnstile_site_key: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="turnstile_secret_key">{__('Secret Key')}</Label>
|
||||
<Input
|
||||
id="turnstile_secret_key"
|
||||
type="password"
|
||||
placeholder="0x..."
|
||||
value={settings.turnstile_secret_key}
|
||||
onChange={(e) => setSettings({ ...settings, turnstile_secret_key: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</RadioGroup>
|
||||
</div>
|
||||
</SettingsCard>
|
||||
</SettingsLayout>
|
||||
);
|
||||
}
|
||||
@@ -48,6 +48,7 @@ interface Subscription {
|
||||
end_date: string | null;
|
||||
last_payment_date: string | null;
|
||||
payment_method: string;
|
||||
payment_method_title?: string;
|
||||
pause_count: number;
|
||||
failed_payment_count: number;
|
||||
cancel_reason: string | null;
|
||||
@@ -65,6 +66,7 @@ const statusColors: Record<string, string> = {
|
||||
'cancelled': 'bg-gray-100 text-gray-800',
|
||||
'expired': 'bg-red-100 text-red-800',
|
||||
'pending-cancel': 'bg-orange-100 text-orange-800',
|
||||
'draft': 'bg-gray-100 text-gray-600',
|
||||
};
|
||||
|
||||
const statusLabels: Record<string, string> = {
|
||||
@@ -74,6 +76,7 @@ const statusLabels: Record<string, string> = {
|
||||
'cancelled': __('Cancelled'),
|
||||
'expired': __('Expired'),
|
||||
'pending-cancel': __('Pending Cancel'),
|
||||
'draft': __('Draft'),
|
||||
};
|
||||
|
||||
const orderTypeLabels: Record<string, string> = {
|
||||
@@ -83,6 +86,22 @@ const orderTypeLabels: Record<string, string> = {
|
||||
'resubscribe': __('Resubscribe'),
|
||||
};
|
||||
|
||||
const formatPrice = (amount: string | number) => {
|
||||
const val = typeof amount === 'string' ? parseFloat(amount) : amount;
|
||||
if (isNaN(val)) return amount;
|
||||
|
||||
// Simple formatting using browser's locale but keeping currency from store
|
||||
try {
|
||||
return new Intl.NumberFormat(window.WNW_STORE?.locale || 'en-US', {
|
||||
style: 'currency',
|
||||
currency: window.WNW_STORE?.currency || 'USD',
|
||||
minimumFractionDigits: window.WNW_STORE?.decimals || 2,
|
||||
}).format(val);
|
||||
} catch (e) {
|
||||
return (window.WNW_STORE?.currency_symbol || '$') + val;
|
||||
}
|
||||
};
|
||||
|
||||
async function fetchSubscription(id: string) {
|
||||
const res = await fetch(`${window.WNW_API.root}/subscriptions/${id}`, {
|
||||
headers: { 'X-WP-Nonce': window.WNW_API.nonce },
|
||||
@@ -257,7 +276,7 @@ export default function SubscriptionDetail() {
|
||||
{subscription.billing_schedule}
|
||||
</p>
|
||||
<p className="text-lg font-semibold mt-1">
|
||||
{window.WNW_STORE?.currency_symbol}{subscription.recurring_amount}
|
||||
{formatPrice(subscription.recurring_amount)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -317,7 +336,7 @@ export default function SubscriptionDetail() {
|
||||
<div className="text-sm text-muted-foreground">{__('Payment Method')}</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<CreditCard className="w-4 h-4" />
|
||||
{subscription.payment_method || __('Not set')}
|
||||
{subscription.payment_method_title || subscription.payment_method || __('Not set')}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
@@ -368,29 +387,32 @@ export default function SubscriptionDetail() {
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
subscription.orders?.map((order) => (
|
||||
<TableRow key={order.id}>
|
||||
<TableCell>
|
||||
<Link
|
||||
to={`/orders/${order.order_id}`}
|
||||
className="text-primary hover:underline font-medium"
|
||||
>
|
||||
#{order.order_id}
|
||||
</Link>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant="outline">
|
||||
{orderTypeLabels[order.order_type] || order.order_type}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<span className="capitalize">{order.order_status?.replace('wc-', '')}</span>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{new Date(order.created_at).toLocaleDateString()}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
subscription.orders?.map((order) => {
|
||||
const rawStatus = order.order_status?.replace('wc-', '') || 'pending';
|
||||
return (
|
||||
<TableRow key={order.id}>
|
||||
<TableCell>
|
||||
<Link
|
||||
to={`/orders/${order.order_id}`}
|
||||
className="text-primary hover:underline font-medium"
|
||||
>
|
||||
#{order.order_id}
|
||||
</Link>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant="outline">
|
||||
{orderTypeLabels[order.order_type] || order.order_type}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<span className="capitalize">{statusLabels[rawStatus] || rawStatus}</span>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{new Date(order.created_at).toLocaleDateString()}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useNavigate, useSearchParams, Link } from 'react-router-dom';
|
||||
import { Repeat, MoreHorizontal, Play, Pause, XCircle, RefreshCw, Eye, Calendar, User, Package } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
@@ -151,6 +152,23 @@ export default function SubscriptionsIndex() {
|
||||
const total = data?.total || 0;
|
||||
const totalPages = Math.ceil(total / 20);
|
||||
|
||||
// Checkbox logic
|
||||
const [selectedIds, setSelectedIds] = React.useState<number[]>([]);
|
||||
|
||||
const toggleAll = () => {
|
||||
if (selectedIds.length === subscriptions.length) {
|
||||
setSelectedIds([]);
|
||||
} else {
|
||||
setSelectedIds(subscriptions.map(s => s.id));
|
||||
}
|
||||
};
|
||||
|
||||
const toggleRow = (id: number) => {
|
||||
setSelectedIds(prev =>
|
||||
prev.includes(id) ? prev.filter(i => i !== id) : [...prev, id]
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
@@ -181,6 +199,13 @@ export default function SubscriptionsIndex() {
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-12 p-3">
|
||||
<Checkbox
|
||||
checked={subscriptions.length > 0 && selectedIds.length === subscriptions.length}
|
||||
onCheckedChange={toggleAll}
|
||||
aria-label={__('Select all')}
|
||||
/>
|
||||
</TableHead>
|
||||
<TableHead className="w-[80px]">{__('ID')}</TableHead>
|
||||
<TableHead>{__('Customer')}</TableHead>
|
||||
<TableHead>{__('Product')}</TableHead>
|
||||
@@ -215,7 +240,18 @@ export default function SubscriptionsIndex() {
|
||||
) : (
|
||||
subscriptions.map((sub) => (
|
||||
<TableRow key={sub.id}>
|
||||
<TableCell className="font-medium">#{sub.id}</TableCell>
|
||||
<TableCell className="p-3">
|
||||
<Checkbox
|
||||
checked={selectedIds.includes(sub.id)}
|
||||
onCheckedChange={() => toggleRow(sub.id)}
|
||||
aria-label={__('Select subscription')}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell className="font-medium">
|
||||
<Link to={`/subscriptions/${sub.id}`} className="hover:underline">
|
||||
#{sub.id}
|
||||
</Link>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div>
|
||||
<div className="font-medium">{sub.user_name}</div>
|
||||
|
||||
15
admin-spa/src/types/window.d.ts
vendored
15
admin-spa/src/types/window.d.ts
vendored
@@ -47,12 +47,25 @@ interface WNW_CONFIG {
|
||||
pluginUrl?: string;
|
||||
}
|
||||
|
||||
interface WNW_Store {
|
||||
locale?: string;
|
||||
currency?: string;
|
||||
currency_symbol?: string;
|
||||
currency_pos?: string;
|
||||
thousand_sep?: string;
|
||||
decimal_sep?: string;
|
||||
decimals?: number;
|
||||
symbol?: string; // Sometimes mapped
|
||||
position?: string;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
WNW_API?: WNW_API_Config;
|
||||
WNW_API: WNW_API_Config; // Make required to avoid "possibly undefined" check in every usage if we are sure it exists
|
||||
wnw?: WNW_Config;
|
||||
WNW_WC_MENUS?: WNW_WC_MENUS;
|
||||
WNW_CONFIG?: WNW_CONFIG;
|
||||
WNW_STORE?: WNW_Store;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user