diff --git a/package.json b/package.json index b047fe2..a0c6a85 100644 --- a/package.json +++ b/package.json @@ -68,6 +68,7 @@ "@dnd-kit/sortable": "^10.0.0", "@dnd-kit/utilities": "^3.2.2", "@wordpress/dataviews": "^11.3.0", + "@wordpress/hooks": "^4.39.0", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "input-otp": "^1.4.2", diff --git a/src/components/License.stories.tsx b/src/components/License.stories.tsx new file mode 100644 index 0000000..4f1a839 --- /dev/null +++ b/src/components/License.stories.tsx @@ -0,0 +1,339 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import { fn } from "storybook/test"; +import { License, type LicenseStatus } from "./license"; +import React, { useState } from "react"; + +const DefaultHeaderImage = () => ( + + + + + + + + + + + + + + + + + + + + + +); + +const meta = { + title: "Components/License", + component: License, + parameters: { layout: "padded" }, + tags: ["autodocs"], + args: { + onActivate: fn(), + onDeactivate: fn(), + onRefresh: fn(), + onLicenseKeyChange: fn(), + }, + argTypes: { + licenseKey: { control: "text" }, + loading: { control: "boolean" }, + error: { control: "text" }, + pluginName: { control: "text" }, + hookNamespace: { control: "text" }, + }, +} satisfies Meta; + +export default meta; + +type Story = StoryObj; + +// --- Inactive state (no license) --- + +export const Inactive: Story = { + args: { + licenseKey: "", + status: null, + loading: false, + error: "", + pluginName: "MyPlugin Pro", + headerImage: , + }, +}; + +export const InactiveWithKey: Story = { + args: { + licenseKey: "abc123-def456-ghi789-jkl012", + status: null, + loading: false, + error: "", + pluginName: "MyPlugin Pro", + headerImage: , + }, +}; + +// --- Active state --- + +const activeStatus: LicenseStatus = { + is_valid: true, + expiry_days: 120, + data: { + key: "abc123-def456-ghi789-jkl012-mno345", + remaining: 3, + activation_limit: 5, + }, +}; + +export const Active: Story = { + args: { + licenseKey: "abc123-def456-ghi789-jkl012-mno345", + status: activeStatus, + loading: false, + error: "", + pluginName: "MyPlugin Pro", + headerImage: , + }, +}; + +// --- Active with perpetual license --- + +const perpetualStatus: LicenseStatus = { + is_valid: true, + expiry_days: 0, + data: { + key: "abc123-def456-ghi789-jkl012-mno345", + remaining: 10, + activation_limit: 25, + }, +}; + +export const ActivePerpetual: Story = { + args: { + licenseKey: "abc123-def456-ghi789-jkl012-mno345", + status: perpetualStatus, + loading: false, + error: "", + pluginName: "MyPlugin Pro", + headerImage: , + }, +}; + +// --- Loading state --- + +export const Loading: Story = { + args: { + licenseKey: "", + status: null, + loading: true, + error: "", + pluginName: "MyPlugin Pro", + headerImage: , + }, +}; + +// --- Error state --- + +export const WithError: Story = { + args: { + licenseKey: "invalid-key", + status: null, + loading: false, + error: "The license key you entered is invalid. Please check and try again.", + pluginName: "MyPlugin Pro", + headerImage: , + }, +}; + +// --- Without header image --- + +export const WithoutHeaderImage: Story = { + args: { + licenseKey: "", + status: null, + loading: false, + error: "", + pluginName: "wePos", + }, +}; + +// --- Custom labels --- + +export const CustomLabels: Story = { + args: { + licenseKey: "", + status: null, + loading: false, + error: "", + pluginName: "Starter Plugin", + headerImage: , + labels: { + title: "Activation", + activationTitle: "Product Activation", + activateButton: "Activate Now", + licenseKeyPlaceholder: "Paste your license key...", + }, + }, +}; + +// --- Interactive playground --- + +const InteractiveTemplate = () => { + const [licenseKey, setLicenseKey] = useState(""); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(""); + const [status, setStatus] = useState(null); + const handleActivate = async () => { + setLoading(true); + setError(""); + // Simulate API call + await new Promise((resolve) => setTimeout(resolve, 1500)); + + if (licenseKey === "invalid") { + setError("Invalid license key. Please try again."); + setLoading(false); + return; + } + + setStatus({ + is_valid: true, + expiry_days: 365, + data: { + key: licenseKey, + remaining: 4, + activation_limit: 5, + }, + }); + setLoading(false); + }; + + const handleDeactivate = async (closeDialog: () => void) => { + setLoading(true); + setError(""); + await new Promise((resolve) => setTimeout(resolve, 1000)); + setStatus(null); + setLicenseKey(""); + setLoading(false); + closeDialog(); + }; + + const handleRefresh = async () => { + setLoading(true); + await new Promise((resolve) => setTimeout(resolve, 800)); + setLoading(false); + }; + + return ( + } + /> + ); +}; + +export const Interactive: Story = { + render: () => , + args: { + licenseKey: "", + status: null, + loading: false, + error: "", + }, + parameters: { + docs: { + description: { + story: + "A fully interactive demo with simulated API calls. Type any key to activate (type 'invalid' to see error state).", + }, + }, + }, +}; diff --git a/src/components/license.tsx b/src/components/license.tsx new file mode 100644 index 0000000..c582ddf --- /dev/null +++ b/src/components/license.tsx @@ -0,0 +1,498 @@ +import { useMemo, useState, type HTMLAttributes, type ReactNode } from "react"; +import { RawHTML } from "@wordpress/element"; +import { doAction } from "@wordpress/hooks"; +import { Calendar, Eye, EyeOff, Info, KeyRound, Loader, LoaderCircle, RefreshCw } from "lucide-react"; +import { cn } from "@/lib/utils"; +import { Card } from "@/components/ui/card"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { + AlertDialog, + AlertDialogTrigger, + AlertDialogContent, + AlertDialogHeader, + AlertDialogTitle, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogAction, + AlertDialogCancel, +} from "@/components/ui/alert-dialog"; +import { Badge } from "./ui"; + +export interface LicenseStatus { + is_valid: boolean; + expiry_days?: number; + data?: { + key?: string; + remaining?: number; + activation_limit?: number; + }; +} + +export interface LicenseLabels { + title?: string; + activationTitle?: string; + activationDescription?: string; + activateLicenseHeading?: string; + activateLicenseSubheading?: string; + licenseKeyLabel?: string; + licenseKeyPlaceholder?: string; + activateButton?: string; + deactivateButton?: string; + refreshButton?: string; + activeStatus?: string; + maskedKeyInfo?: string; + activationsRemainingTitle?: string; + usageLabel?: string; + outOfLabel?: string; + activationInfoUnavailable?: string; + licenseExpiryTitle?: string; + perpetualLicenseMessage?: string; + expiryMessage?: (date: string, days: number) => string; + expiryInfoUnavailable?: string; + deactivateConfirmTitle?: string; + deactivateConfirmMessage?: string; + cancelButton?: string; + confirmDeactivateButton?: string; + showKeyLabel?: string; + hideKeyLabel?: string; +} + +export interface LicenseClassNames { + /** Root wrapper div */ + root?: string; + /** Page title (h1) */ + title?: string; + /** Loading overlay backdrop */ + loadingOverlay?: string; + /** Loading spinner icon */ + loadingSpinner?: string; + /** Main license card */ + card?: string; + /** Card header section (contains title, badge, description, image) */ + header?: string; + /** Header title (h2) */ + headerTitle?: string; + /** Header description paragraph */ + headerDescription?: string; + /** Header image container */ + headerImageContainer?: string; + /** Card body section */ + body?: string; + /** Activate license heading section (icon + text) */ + activateHeading?: string; + /** Activate heading icon container */ + activateHeadingIcon?: string; + /** Activate heading title (h3) */ + activateHeadingTitle?: string; + /** Activate heading subtitle */ + activateHeadingSubtitle?: string; + /** License key label */ + licenseKeyLabel?: string; + /** License key input */ + input?: string; + /** Error message text */ + errorMessage?: string; + /** Masked key info row */ + maskedKeyInfo?: string; + /** Action buttons container */ + actions?: string; + /** Status cards grid (activations + expiry) */ + statusGrid?: string; + /** Activations remaining card */ + activationsCard?: string; + /** License expiry card */ + expiryCard?: string; +} + +export interface LicenseProps extends HTMLAttributes { + /** Current license key value */ + licenseKey: string; + /** Callback when the license key input changes */ + onLicenseKeyChange: (key: string) => void; + /** Current license status data */ + status: LicenseStatus | null; + /** Whether a license operation is in progress */ + loading: boolean; + /** Current error message, empty string if no error */ + error: string; + /** Called when user clicks the Activate button */ + onActivate: () => void; + /** Called when user confirms deactivation. Receives `closeDialog` to dismiss the confirmation when ready. */ + onDeactivate: (closeDialog: () => void) => void; + /** Called when user clicks the Refresh button */ + onRefresh: () => void; + /** Custom header image/illustration rendered in the top-right of the card */ + headerImage?: ReactNode; + /** Namespace for WordPress hooks (doAction). Defaults to 'plugin_ui' */ + hookNamespace?: string; + /** Plugin name used in default label strings. Defaults to 'Plugin' */ + pluginName?: string; + /** Override default label strings for i18n or customization */ + labels?: LicenseLabels; + /** Format a date string for display (e.g., using dateI18n from @wordpress/date) */ + formatDate?: (date: Date) => string; + /** Override Tailwind class names for individual sections of the component */ + classNames?: LicenseClassNames; +} + +const maskKey = (key: string): string => { + if (!key) return ""; + if (key.length <= 8) return "*".repeat(key.length); + return key.substring(0, 18) + "*".repeat(Math.max(10, key.length - 18)); +}; + +const getUsagePercentage = ( + limit: number, + remaining: number, +): number => { + return Math.min(100, Math.round(((limit - remaining) / (limit || 1)) * 100)); +}; + +const defaultFormatDate = (date: Date): string => { + return date.toLocaleDateString(undefined, { + year: "numeric", + month: "long", + day: "numeric", + }); +}; + +export function License({ + licenseKey, + onLicenseKeyChange, + status, + loading, + error, + onActivate, + onDeactivate, + onRefresh, + headerImage, + hookNamespace = "plugin_ui", + pluginName = "Plugin", + labels: labelOverrides = {}, + formatDate = defaultFormatDate, + classNames: cx = {}, + className, + ...props +}: LicenseProps) { + const [showKey, setShowKey] = useState(false); + const [deactivateDialogOpen, setDeactivateDialogOpen] = useState(false); + + const hasActive = useMemo(() => Boolean(status?.is_valid), [status]); + + const labels = useMemo(() => { + const defaults: Required = { + title: "License", + activationTitle: "License Activation", + activationDescription: `Activate ${pluginName} with your license key for automatic updates and expert support from your dashboard.`, + activateLicenseHeading: "Activate License", + activateLicenseSubheading: + "Enter your license key to activate your software", + licenseKeyLabel: `${pluginName} License Key`, + licenseKeyPlaceholder: "Enter your key here", + activateButton: "Activate License", + deactivateButton: "Deactivate License", + refreshButton: "Refresh", + activeStatus: "Active", + maskedKeyInfo: "Your license key is masked for security purposes", + activationsRemainingTitle: "Activations Remaining", + usageLabel: "Usage", + outOfLabel: "out of", + activationInfoUnavailable: "Activation information not available.", + licenseExpiryTitle: "License Expiry", + perpetualLicenseMessage: `Your ${pluginName} license is perpetual and will never expire.`, + expiryMessage: (date: string, days: number) => + `Your ${pluginName} license expires on ${date} (${days} days left)`, + expiryInfoUnavailable: "Expiry information not available.", + deactivateConfirmTitle: + "Are you sure you want to deactivate the license key?", + deactivateConfirmMessage: + "Deactivating will disable updates and support until you activate again.", + cancelButton: "Cancel", + confirmDeactivateButton: "Yes, Deactivate", + showKeyLabel: "Show key", + hideKeyLabel: "Hide key", + }; + return { ...defaults, ...labelOverrides }; + }, [labelOverrides, pluginName]); + + const maskedOrReal = showKey ? licenseKey : maskKey(licenseKey); + + const handleActivate = () => { + doAction(`${hookNamespace}_license_action`, { + action: "activate", + licenseKey, + }); + onActivate(); + }; + + const handleDeactivate = () => { + doAction(`${hookNamespace}_license_action`, { + action: "deactivate", + }); + onDeactivate(() => setDeactivateDialogOpen(false)); + }; + + const handleRefresh = () => { + doAction(`${hookNamespace}_license_action`, { + action: "refresh", + }); + onRefresh(); + }; + + const expiryDate = + typeof status?.expiry_days === "number" && status.expiry_days > 0 + ? new Date(Date.now() + status.expiry_days * 24 * 60 * 60 * 1000) + : null; + + return ( +
+

{labels.title}

+ +
+ {loading && ( +
+
+ +
+
+ )} + + + {/* Header section */} +
+
+
+
+

{labels.activationTitle}

+ {hasActive && ( + + {labels.activeStatus} + + )} +
+

+ {labels.activationDescription} +

+
+ {headerImage && ( +
+ {headerImage} +
+ )} +
+
+ + {/* Body section */} +
+ {/* Activate heading (shown when not active) */} + {!hasActive && ( +
+
+ +
+
+

+ {labels.activateLicenseHeading} +

+ + {labels.activateLicenseSubheading} + +
+
+ )} + + {/* License key label */} +
+ {labels.licenseKeyLabel} +
+ + {/* License key input */} +
+ onLicenseKeyChange(e.target.value)} + disabled={loading || hasActive} + className={cn( + "pr-10", + error && "border-destructive focus-visible:border-destructive focus-visible:ring-destructive/20", + cx.input + )} + /> + +
+ + {/* Error message */} + {error && ( +

{error}

+ )} + + {/* Masked key info */} + {!error && hasActive && ( +
+ + {labels.maskedKeyInfo} +
+ )} + + {/* Action buttons */} +
+ {hasActive ? ( + <> + { if (!loading) setDeactivateDialogOpen(open); }}> + + + + + + + {labels.deactivateConfirmTitle} + + + {labels.deactivateConfirmMessage} + + + + + {labels.cancelButton} + + + {labels.confirmDeactivateButton} + + + + + + + ) : ( + + )} +
+
+
+ + {/* Status cards (shown when active) */} + {hasActive && ( +
+ {/* Activations Remaining card */} + +
+
+ +
+
+ {labels.activationsRemainingTitle} +
+
+ {typeof status?.data?.remaining !== "undefined" && + typeof status?.data?.activation_limit !== "undefined" ? ( + <> +
+ {labels.usageLabel} +
+
+
+
+
+
+ {getUsagePercentage( + status.data.activation_limit, + status.data.remaining, + )} + % +
+
+
+ {`${status.data.activation_limit - status.data.remaining} ${labels.outOfLabel} ${status.data.activation_limit}`} +
+ + ) : ( +
+ {labels.activationInfoUnavailable} +
+ )} + + + {/* License Expiry card */} + +
+
+ +
+
+ {labels.licenseExpiryTitle} +
+
+ {typeof status?.expiry_days === "number" ? ( + status.expiry_days <= 0 ? ( +
+ {labels.perpetualLicenseMessage} +
+ ) : ( +
+ + + {labels.expiryMessage!( + expiryDate ? formatDate(expiryDate) : "", + status.expiry_days, + )} + +
+ ) + ) : ( +
+ {labels.expiryInfoUnavailable} +
+ )} +
+
+ )} +
+ +
+ ); +} diff --git a/src/index.ts b/src/index.ts index 12dd9b9..db1ae6b 100644 --- a/src/index.ts +++ b/src/index.ts @@ -260,6 +260,7 @@ export { export { TopBar, type TopBarProps } from './components/top-bar' export { CrownIcon, type CrownIconProps } from './components/crown-icon' export { ButtonToggleGroup, type ButtonToggleGroupProps, type ButtonToggleGroupItem } from './components/button-toggle-group' +export { License, type LicenseProps, type LicenseLabels, type LicenseStatus, type LicenseClassNames } from './components/license' // Settings (schema-driven settings page) export {