From 60d749cd65101d5d7f7122487a44fba1f63cbfc5 Mon Sep 17 00:00:00 2001 From: Dwindi Ramadhana Date: Mon, 5 Jan 2026 17:10:04 +0700 Subject: [PATCH] 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 --- .../Products/partials/ProductFormTabbed.tsx | 20 +++ .../Products/partials/tabs/GeneralTab.tsx | 153 +++++++++++++++++- includes/Api/ProductsController.php | 27 ++++ 3 files changed, 192 insertions(+), 8 deletions(-) diff --git a/admin-spa/src/routes/Products/partials/ProductFormTabbed.tsx b/admin-spa/src/routes/Products/partials/ProductFormTabbed.tsx index 23af01e..9f14d23 100644 --- a/admin-spa/src/routes/Products/partials/ProductFormTabbed.tsx +++ b/admin-spa/src/routes/Products/partials/ProductFormTabbed.tsx @@ -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(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} /> diff --git a/admin-spa/src/routes/Products/partials/tabs/GeneralTab.tsx b/admin-spa/src/routes/Products/partials/tabs/GeneralTab.tsx index 60f215c..903b4f6 100644 --- a/admin-spa/src/routes/Products/partials/tabs/GeneralTab.tsx +++ b/admin-spa/src/routes/Products/partials/tabs/GeneralTab.tsx @@ -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(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 ( @@ -181,7 +222,7 @@ export function GeneralTab({

{__('First image will be the featured image. Drag to reorder.')}

- + {/* Image Upload Button */}

- {type === 'variable' + {type === 'variable' ? __('Base price (can override per variation)') : __('Base price before discounts')}

@@ -387,8 +428,104 @@ export function GeneralTab({ {__('Featured product (show in featured sections)')} + + {/* Licensing option */} + {setLicensingEnabled && ( + <> +
+ setLicensingEnabled(checked as boolean)} + /> + +
+ + {/* Licensing settings panel */} + {licensingEnabled && ( +
+
+
+ + setLicenseActivationLimit?.(e.target.value)} + className="mt-1" + /> +

+ {__('0 or empty = use global default')} +

+
+
+ + setLicenseDurationDays?.(e.target.value)} + className="mt-1" + /> +

+ {__('0 = never expires')} +

+
+
+
+ )} + + )} + + {/* Direct Cart Links - Simple products only */} + {productId && type === 'simple' && ( + <> + +
+ +

+ {__('Share these links to add this product directly to cart or checkout')} +

+
+ + +
+
+ + )}
); diff --git a/includes/Api/ProductsController.php b/includes/Api/ProductsController.php index ed8847d..3777e37 100644 --- a/includes/Api/ProductsController.php +++ b/includes/Api/ProductsController.php @@ -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();