feat: Add Copy Cart/Checkout links and licensing settings to product editor

Copy Cart/Checkout Links:
- Added to GeneralTab for simple products (same pattern as variations)
- Link generation with add-to-cart and redirect params

Licensing Settings:
- 'Enable licensing for this product' checkbox in Additional Options
- License settings panel: activation limit, duration (days)
- State management in ProductFormTabbed
- Backend: ProductsController saves/loads licensing meta fields

Backend:
- _licensing_enabled, _license_activation_limit, _license_duration_days post meta
This commit is contained in:
Dwindi Ramadhana
2026-01-05 17:10:04 +07:00
parent 26ab626966
commit 60d749cd65
3 changed files with 192 additions and 8 deletions

View File

@@ -36,6 +36,10 @@ export type ProductFormData = {
downloads?: DownloadableFile[];
download_limit?: string;
download_expiry?: string;
// Licensing
licensing_enabled?: boolean;
license_activation_limit?: string;
license_duration_days?: string;
};
type Props = {
@@ -82,6 +86,9 @@ export function ProductFormTabbed({
const [downloads, setDownloads] = useState<DownloadableFile[]>(initial?.downloads || []);
const [downloadLimit, setDownloadLimit] = useState(initial?.download_limit || '');
const [downloadExpiry, setDownloadExpiry] = useState(initial?.download_expiry || '');
const [licensingEnabled, setLicensingEnabled] = useState(initial?.licensing_enabled || false);
const [licenseActivationLimit, setLicenseActivationLimit] = useState(initial?.license_activation_limit || '');
const [licenseDurationDays, setLicenseDurationDays] = useState(initial?.license_duration_days || '');
const [submitting, setSubmitting] = useState(false);
// Update form state when initial data changes (for edit mode)
@@ -109,6 +116,9 @@ export function ProductFormTabbed({
setDownloads(initial.downloads || []);
setDownloadLimit(initial.download_limit || '');
setDownloadExpiry(initial.download_expiry || '');
setLicensingEnabled(initial.licensing_enabled || false);
setLicenseActivationLimit(initial.license_activation_limit || '');
setLicenseDurationDays(initial.license_duration_days || '');
}
}, [initial, mode]);
@@ -168,6 +178,9 @@ export function ProductFormTabbed({
downloads: downloadable ? downloads : undefined,
download_limit: downloadable ? downloadLimit : undefined,
download_expiry: downloadable ? downloadExpiry : undefined,
licensing_enabled: licensingEnabled,
license_activation_limit: licensingEnabled ? licenseActivationLimit : undefined,
license_duration_days: licensingEnabled ? licenseDurationDays : undefined,
};
await onSubmit(payload);
@@ -217,6 +230,13 @@ export function ProductFormTabbed({
setRegularPrice={setRegularPrice}
salePrice={salePrice}
setSalePrice={setSalePrice}
productId={productId}
licensingEnabled={licensingEnabled}
setLicensingEnabled={setLicensingEnabled}
licenseActivationLimit={licenseActivationLimit}
setLicenseActivationLimit={setLicenseActivationLimit}
licenseDurationDays={licenseDurationDays}
setLicenseDurationDays={setLicenseDurationDays}
/>
</FormSection>

View File

@@ -1,4 +1,4 @@
import React from 'react';
import React, { useState } from 'react';
import { __ } from '@/lib/i18n';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
@@ -8,7 +8,8 @@ import { Button } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Separator } from '@/components/ui/separator';
import { DollarSign, Upload, X, Image as ImageIcon } from 'lucide-react';
import { DollarSign, Upload, X, Image as ImageIcon, Copy, Check, Key } from 'lucide-react';
import { toast } from 'sonner';
import { getStoreCurrency } from '@/lib/currency';
import { RichTextEditor } from '@/components/RichTextEditor';
import { openWPMediaGallery } from '@/lib/wp-media';
@@ -40,6 +41,15 @@ type GeneralTabProps = {
setRegularPrice: (value: string) => void;
salePrice: string;
setSalePrice: (value: string) => void;
// For copy links
productId?: number;
// Licensing
licensingEnabled?: boolean;
setLicensingEnabled?: (value: boolean) => void;
licenseActivationLimit?: string;
setLicenseActivationLimit?: (value: string) => void;
licenseDurationDays?: string;
setLicenseDurationDays?: (value: string) => void;
};
export function GeneralTab({
@@ -67,14 +77,45 @@ export function GeneralTab({
setRegularPrice,
salePrice,
setSalePrice,
productId,
licensingEnabled,
setLicensingEnabled,
licenseActivationLimit,
setLicenseActivationLimit,
licenseDurationDays,
setLicenseDurationDays,
}: GeneralTabProps) {
const savingsPercent =
const savingsPercent =
salePrice && regularPrice && parseFloat(salePrice) < parseFloat(regularPrice)
? Math.round((1 - parseFloat(salePrice) / parseFloat(regularPrice)) * 100)
: 0;
const store = getStoreCurrency();
// Copy link state and helpers
const [copiedLink, setCopiedLink] = useState<string | null>(null);
const siteUrl = window.location.origin;
const spaPagePath = '/store';
const generateSimpleLink = (redirect: 'cart' | 'checkout' = 'cart') => {
if (!productId) return '';
const params = new URLSearchParams();
params.set('add-to-cart', productId.toString());
params.set('redirect', redirect);
return `${siteUrl}${spaPagePath}?${params.toString()}`;
};
const copyToClipboard = async (link: string, label: string) => {
try {
await navigator.clipboard.writeText(link);
setCopiedLink(link);
toast.success(`${label} link copied!`);
setTimeout(() => setCopiedLink(null), 2000);
} catch (err) {
toast.error('Failed to copy link');
}
};
return (
<Card>
<CardHeader>
@@ -181,7 +222,7 @@ export function GeneralTab({
<p className="text-xs text-muted-foreground mt-1 mb-3">
{__('First image will be the featured image. Drag to reorder.')}
</p>
{/* Image Upload Button */}
<div className="space-y-3">
<Button
@@ -227,7 +268,7 @@ export function GeneralTab({
e.currentTarget.classList.remove('ring-2', 'ring-primary', 'ring-offset-2');
const fromIndex = parseInt(e.dataTransfer.getData('text/plain'));
const toIndex = index;
if (fromIndex !== toIndex) {
const newImages = [...images];
const [movedImage] = newImages.splice(fromIndex, 1);
@@ -313,7 +354,7 @@ export function GeneralTab({
/>
</div>
<p className="text-xs text-muted-foreground mt-1">
{type === 'variable'
{type === 'variable'
? __('Base price (can override per variation)')
: __('Base price before discounts')}
</p>
@@ -387,8 +428,104 @@ export function GeneralTab({
{__('Featured product (show in featured sections)')}
</Label>
</div>
{/* Licensing option */}
{setLicensingEnabled && (
<>
<div className="flex items-center space-x-2">
<Checkbox
id="licensing-enabled"
checked={licensingEnabled || false}
onCheckedChange={(checked) => setLicensingEnabled(checked as boolean)}
/>
<Label htmlFor="licensing-enabled" className="cursor-pointer font-normal flex items-center gap-1">
<Key className="h-3 w-3" />
{__('Enable licensing for this product')}
</Label>
</div>
{/* Licensing settings panel */}
{licensingEnabled && (
<div className="ml-6 p-3 bg-muted/50 rounded-lg space-y-3">
<div className="grid grid-cols-2 gap-3">
<div>
<Label className="text-xs">{__('Activation Limit')}</Label>
<Input
type="number"
min="0"
placeholder={__('0 = unlimited')}
value={licenseActivationLimit || ''}
onChange={(e) => setLicenseActivationLimit?.(e.target.value)}
className="mt-1"
/>
<p className="text-xs text-muted-foreground mt-1">
{__('0 or empty = use global default')}
</p>
</div>
<div>
<Label className="text-xs">{__('License Duration (Days)')}</Label>
<Input
type="number"
min="0"
placeholder={__('365')}
value={licenseDurationDays || ''}
onChange={(e) => setLicenseDurationDays?.(e.target.value)}
className="mt-1"
/>
<p className="text-xs text-muted-foreground mt-1">
{__('0 = never expires')}
</p>
</div>
</div>
</div>
)}
</>
)}
</div>
</div>
{/* Direct Cart Links - Simple products only */}
{productId && type === 'simple' && (
<>
<Separator />
<div className="space-y-3">
<Label>{__('Direct-to-Cart Links')}</Label>
<p className="text-xs text-muted-foreground">
{__('Share these links to add this product directly to cart or checkout')}
</p>
<div className="flex gap-2">
<Button
type="button"
size="sm"
variant="outline"
onClick={() => copyToClipboard(generateSimpleLink('cart'), 'Cart')}
className="flex-1"
>
{copiedLink === generateSimpleLink('cart') ? (
<Check className="h-3 w-3 mr-1" />
) : (
<Copy className="h-3 w-3 mr-1" />
)}
{__('Copy Cart Link')}
</Button>
<Button
type="button"
size="sm"
variant="outline"
onClick={() => copyToClipboard(generateSimpleLink('checkout'), 'Checkout')}
className="flex-1"
>
{copiedLink === generateSimpleLink('checkout') ? (
<Check className="h-3 w-3 mr-1" />
) : (
<Copy className="h-3 w-3 mr-1" />
)}
{__('Copy Checkout Link')}
</Button>
</div>
</div>
</>
)}
</CardContent>
</Card>
);

View File

@@ -413,6 +413,17 @@ class ProductsController {
$product->save();
// Licensing meta
if (isset($data['licensing_enabled'])) {
update_post_meta($product->get_id(), '_licensing_enabled', $data['licensing_enabled'] ? 'yes' : 'no');
}
if (isset($data['license_activation_limit'])) {
update_post_meta($product->get_id(), '_license_activation_limit', self::sanitize_number($data['license_activation_limit']));
}
if (isset($data['license_duration_days'])) {
update_post_meta($product->get_id(), '_license_duration_days', self::sanitize_number($data['license_duration_days']));
}
// Handle variations for variable products
if ($type === 'variable' && !empty($data['attributes']) && is_array($data['attributes'])) {
self::save_product_attributes($product, $data['attributes']);
@@ -546,6 +557,17 @@ class ProductsController {
$product->save();
// Licensing meta
if (isset($data['licensing_enabled'])) {
update_post_meta($product->get_id(), '_licensing_enabled', $data['licensing_enabled'] ? 'yes' : 'no');
}
if (isset($data['license_activation_limit'])) {
update_post_meta($product->get_id(), '_license_activation_limit', self::sanitize_number($data['license_activation_limit']));
}
if (isset($data['license_duration_days'])) {
update_post_meta($product->get_id(), '_license_duration_days', self::sanitize_number($data['license_duration_days']));
}
// Allow plugins to perform additional updates (Level 1 compatibility)
do_action('woonoow/product_updated', $product, $data, $request);
@@ -738,6 +760,11 @@ class ProductsController {
$data['download_expiry'] = $product->get_download_expiry() !== -1 ? (string) $product->get_download_expiry() : '';
}
// Licensing fields
$data['licensing_enabled'] = get_post_meta($product->get_id(), '_licensing_enabled', true) === 'yes';
$data['license_activation_limit'] = get_post_meta($product->get_id(), '_license_activation_limit', true) ?: '';
$data['license_duration_days'] = get_post_meta($product->get_id(), '_license_duration_days', true) ?: '';
// Images array (URLs) for frontend - featured + gallery
$images = [];
$featured_image_id = $product->get_image_id();