create-nextblock 0.14.0 → 0.14.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/docker-template/.dockerignore +23 -23
- package/docker-template/.env.docker.example +69 -69
- package/docker-template/docker/db/init/99-jwt.sql +6 -6
- package/docker-template/docker/db/init/99-roles.sql +25 -25
- package/docker-template/docker/kong/kong.yml +112 -112
- package/docker-template/scripts/docker-setup.mjs +310 -310
- package/libs/db/tsconfig.lib.json +3 -3
- package/libs/editor/tsconfig.lib.json +3 -3
- package/libs/ui/tsconfig.lib.json +3 -3
- package/libs/utils/tsconfig.json +3 -3
- package/package.json +1 -1
- package/project.json +19 -19
- package/templates/nextblock-template/.browserslistrc +11 -11
- package/templates/nextblock-template/.dockerignore +23 -23
- package/templates/nextblock-template/README.md +34 -34
- package/templates/nextblock-template/app/(auth-pages)/layout.tsx +9 -9
- package/templates/nextblock-template/app/(auth-pages)/post-sign-in/page.tsx +27 -27
- package/templates/nextblock-template/app/(auth-pages)/sign-up/page.tsx +46 -46
- package/templates/nextblock-template/app/(auth-pages)/two-factor/page.tsx +51 -51
- package/templates/nextblock-template/app/ToasterProvider.tsx +17 -17
- package/templates/nextblock-template/app/[slug]/pageClientActions.ts +7 -7
- package/templates/nextblock-template/app/actions/consent.ts +57 -57
- package/templates/nextblock-template/app/actions/postActions.ts +146 -146
- package/templates/nextblock-template/app/actions/twoFactorEmail.ts +22 -22
- package/templates/nextblock-template/app/api/ai/cortex/build-widget/route.ts +153 -153
- package/templates/nextblock-template/app/api/ai/generate-blocks/route.ts +96 -96
- package/templates/nextblock-template/app/api/brand/email-logo/route.ts +48 -48
- package/templates/nextblock-template/app/api/cms/check-updates/route.ts +44 -44
- package/templates/nextblock-template/app/api/cms/full-backup/export/route.ts +33 -33
- package/templates/nextblock-template/app/api/cms/full-backup/restore/route.ts +63 -63
- package/templates/nextblock-template/app/api/cron/reset-sandbox/route.ts +3490 -3490
- package/templates/nextblock-template/app/api/cron/reset-sandbox/sandboxResetSql.ts +5749 -5749
- package/templates/nextblock-template/app/api/cron/sync-currencies/route.ts +39 -39
- package/templates/nextblock-template/app/api/custom-blocks/db-relations/route.ts +92 -92
- package/templates/nextblock-template/app/api/custom-blocks/editor-definitions/route.ts +43 -43
- package/templates/nextblock-template/app/api/media/library/route.ts +69 -69
- package/templates/nextblock-template/app/api/media/r2-presigned/route.ts +53 -53
- package/templates/nextblock-template/app/api/media/record/route.ts +160 -160
- package/templates/nextblock-template/app/api/search/route.ts +43 -43
- package/templates/nextblock-template/app/article/[slug]/PostClientContent.tsx +441 -441
- package/templates/nextblock-template/app/auth/callback/route.ts +31 -31
- package/templates/nextblock-template/app/cart/page.tsx +7 -7
- package/templates/nextblock-template/app/cms/blocks/components/CustomBlockEditorPreview.tsx +160 -160
- package/templates/nextblock-template/app/cms/blocks/components/MediaLibraryModal.tsx +149 -149
- package/templates/nextblock-template/app/cms/blocks/editors/DynamicCustomBlockEditor.tsx +167 -167
- package/templates/nextblock-template/app/cms/blocks/editors/ImageBlockEditor.tsx +4 -2
- package/templates/nextblock-template/app/cms/blocks/editors/TextBlockEditor.tsx +90 -90
- package/templates/nextblock-template/app/cms/components/ConnectGitHubButton.tsx +122 -122
- package/templates/nextblock-template/app/cms/components/CortexAiPageContext.tsx +58 -58
- package/templates/nextblock-template/app/cms/components/FeedbackModal.tsx +36 -36
- package/templates/nextblock-template/app/cms/components/SystemAlertsBanner.tsx +112 -112
- package/templates/nextblock-template/app/cms/components/TwoFactorReminderBanner.tsx +45 -45
- package/templates/nextblock-template/app/cms/components/github-connect-actions.ts +102 -102
- package/templates/nextblock-template/app/cms/components/system-alerts-actions.ts +31 -31
- package/templates/nextblock-template/app/cms/custom-blocks/[id]/edit/page.tsx +66 -66
- package/templates/nextblock-template/app/cms/custom-blocks/actions.ts +519 -519
- package/templates/nextblock-template/app/cms/custom-blocks/components/BlocksLibraryTransferControls.tsx +256 -256
- package/templates/nextblock-template/app/cms/custom-blocks/components/DBRelationSelect.tsx +384 -384
- package/templates/nextblock-template/app/cms/custom-blocks/components/ImageR2Picker.tsx +221 -221
- package/templates/nextblock-template/app/cms/custom-blocks/new/page.tsx +12 -12
- package/templates/nextblock-template/app/cms/custom-blocks/page.tsx +438 -438
- package/templates/nextblock-template/app/cms/dashboard/actions.ts +228 -228
- package/templates/nextblock-template/app/cms/dashboard/components/DashboardOnboarding.tsx +130 -130
- package/templates/nextblock-template/app/cms/import-export/actions.ts +226 -226
- package/templates/nextblock-template/app/cms/layout.tsx +73 -73
- package/templates/nextblock-template/app/cms/media/components/FolderNavigator.tsx +273 -273
- package/templates/nextblock-template/app/cms/media/components/FolderTree.tsx +122 -122
- package/templates/nextblock-template/app/cms/media/components/MediaEditForm.tsx +26 -26
- package/templates/nextblock-template/app/cms/media/components/MediaGridClient.tsx +69 -69
- package/templates/nextblock-template/app/cms/navigation/components/NavigationMenuDnd.tsx +3 -3
- package/templates/nextblock-template/app/cms/products/attributes/page.tsx +12 -12
- package/templates/nextblock-template/app/cms/products/categories/page.tsx +12 -12
- package/templates/nextblock-template/app/cms/products/inventory/page.tsx +13 -13
- package/templates/nextblock-template/app/cms/products/new/page.tsx +135 -135
- package/templates/nextblock-template/app/cms/products/productFormData.ts +133 -133
- package/templates/nextblock-template/app/cms/products/settings/page.tsx +5 -5
- package/templates/nextblock-template/app/cms/promotions/PromotionsWorkspace.tsx +456 -456
- package/templates/nextblock-template/app/cms/promotions/actions.ts +115 -115
- package/templates/nextblock-template/app/cms/promotions/page.tsx +31 -31
- package/templates/nextblock-template/app/cms/revisions/service.ts +19 -19
- package/templates/nextblock-template/app/cms/revisions/utils.ts +132 -132
- package/templates/nextblock-template/app/cms/settings/backup-restore/BackupRestoreWorkspace.tsx +1004 -1004
- package/templates/nextblock-template/app/cms/settings/backup-restore/page.tsx +29 -29
- package/templates/nextblock-template/app/cms/settings/bot-protection/page.tsx +24 -24
- package/templates/nextblock-template/app/cms/settings/cortex-ai/SandboxCortexAiSettingsClient.tsx +688 -688
- package/templates/nextblock-template/app/cms/settings/currencies/actions.ts +331 -331
- package/templates/nextblock-template/app/cms/settings/currencies/page.tsx +494 -494
- package/templates/nextblock-template/app/cms/settings/email/page.tsx +28 -28
- package/templates/nextblock-template/app/cms/settings/extra-translations/ExtraTranslationsWorkspace.tsx +767 -767
- package/templates/nextblock-template/app/cms/settings/extra-translations/page.tsx +93 -93
- package/templates/nextblock-template/app/cms/settings/google-analytics/page.tsx +26 -26
- package/templates/nextblock-template/app/cms/settings/logos/[id]/edit/page.tsx +7 -7
- package/templates/nextblock-template/app/cms/settings/logos/components/BrandingSettingsForm.tsx +339 -339
- package/templates/nextblock-template/app/cms/settings/logos/components/DeleteLogoButton.tsx +21 -21
- package/templates/nextblock-template/app/cms/settings/logos/components/LogoForm.tsx +23 -21
- package/templates/nextblock-template/app/cms/settings/logos/components/SetActiveLogoButton.tsx +42 -42
- package/templates/nextblock-template/app/cms/settings/logos/components/SiteSeoSettingsForm.tsx +133 -133
- package/templates/nextblock-template/app/cms/settings/logos/new/page.tsx +8 -8
- package/templates/nextblock-template/app/cms/settings/packages/package-card.tsx +122 -122
- package/templates/nextblock-template/app/cms/settings/privacy/page.tsx +27 -27
- package/templates/nextblock-template/app/cms/settings/registration/page.tsx +27 -27
- package/templates/nextblock-template/app/cms/settings/security/components/SecurityPanel.tsx +9 -7
- package/templates/nextblock-template/app/cms/settings/security/page.tsx +33 -33
- package/templates/nextblock-template/app/cms/settings/taxes/page.tsx +21 -21
- package/templates/nextblock-template/app/cms/shipping/page.tsx +20 -20
- package/templates/nextblock-template/app/cms/users/components/DeleteUserButton.tsx +12 -12
- package/templates/nextblock-template/app/lib/site-settings.ts +105 -105
- package/templates/nextblock-template/app/profile/ProfilePageHeader.tsx +16 -16
- package/templates/nextblock-template/app/profile/ProfilePageMissingState.tsx +9 -9
- package/templates/nextblock-template/app/profile/account-links.ts +22 -22
- package/templates/nextblock-template/app/profile/orders/CustomerOrdersPageClient.tsx +124 -124
- package/templates/nextblock-template/app/profile/orders/page.tsx +19 -19
- package/templates/nextblock-template/app/profile/password/PasswordSettingsPageClient.tsx +128 -128
- package/templates/nextblock-template/app/profile/password/actions.ts +59 -59
- package/templates/nextblock-template/app/profile/password/page.tsx +27 -27
- package/templates/nextblock-template/app/setup/SetupWizard.tsx +678 -678
- package/templates/nextblock-template/app/setup/layout.tsx +13 -13
- package/templates/nextblock-template/app/setup/page.tsx +111 -111
- package/templates/nextblock-template/app/sitemap.ts +130 -130
- package/templates/nextblock-template/components/CartDrawerLoader.tsx +7 -7
- package/templates/nextblock-template/components/DeferredCartDrawer.tsx +23 -23
- package/templates/nextblock-template/components/DeferredGoogleAnalytics.tsx +70 -70
- package/templates/nextblock-template/components/DeferredGoogleTagManager.tsx +70 -70
- package/templates/nextblock-template/components/DeferredSpeedInsights.tsx +69 -69
- package/templates/nextblock-template/components/FeatureImageHero.tsx +47 -47
- package/templates/nextblock-template/components/FooterNavigation.tsx +32 -32
- package/templates/nextblock-template/components/HtmlScriptExecutor.tsx +47 -47
- package/templates/nextblock-template/components/LanguageSwitcher.tsx +2 -2
- package/templates/nextblock-template/components/PublicEnvBootstrap.tsx +30 -30
- package/templates/nextblock-template/components/ResponsiveNav.tsx +14 -14
- package/templates/nextblock-template/components/auth/AuthBotProtection.tsx +182 -182
- package/templates/nextblock-template/components/blocks/PostCardSkeleton.tsx +12 -12
- package/templates/nextblock-template/components/blocks/PostsGridBlock.tsx +12 -12
- package/templates/nextblock-template/components/blocks/ecommerceRendererLoaders.ts +23 -23
- package/templates/nextblock-template/components/blocks/renderers/FormBlockRenderer.tsx +249 -249
- package/templates/nextblock-template/components/blocks/types.ts +7 -7
- package/templates/nextblock-template/components/env-var-warning.tsx +3 -3
- package/templates/nextblock-template/components/form-message.tsx +32 -32
- package/templates/nextblock-template/components/media/YouTubeFacade.tsx +105 -105
- package/templates/nextblock-template/components/media/youtube-embed-replace.tsx +32 -32
- package/templates/nextblock-template/components/privacy/ConsentBanner.tsx +170 -170
- package/templates/nextblock-template/components/privacy/ConsentGatedAnalytics.tsx +70 -70
- package/templates/nextblock-template/components/renderers/CachedDynamicLayoutEngine.tsx +28 -28
- package/templates/nextblock-template/components/renderers/DynamicLayoutEngine.test.tsx +166 -166
- package/templates/nextblock-template/components/renderers/DynamicLayoutEngine.tsx +471 -471
- package/templates/nextblock-template/components/submit-button.tsx +23 -23
- package/templates/nextblock-template/components/theme-switcher.tsx +8 -8
- package/templates/nextblock-template/components/visual-editing/DeferredVisualEditing.tsx +21 -21
- package/templates/nextblock-template/context/AuthContext.tsx +23 -23
- package/templates/nextblock-template/context/language-rest-client.ts +32 -32
- package/templates/nextblock-template/docker/db/init/99-jwt.sql +6 -6
- package/templates/nextblock-template/docker/db/init/99-roles.sql +25 -25
- package/templates/nextblock-template/docker/kong/kong.yml +112 -112
- package/templates/nextblock-template/docs/01-PROJECT-OVERVIEW.md +94 -94
- package/templates/nextblock-template/docs/02-ECOMMERCE-CAPABILITIES.md +364 -364
- package/templates/nextblock-template/docs/03-CMS-AND-EDITOR.md +202 -202
- package/templates/nextblock-template/docs/04-DATABASE-AND-AUTH.md +246 -246
- package/templates/nextblock-template/docs/06-CLI-AND-SCAFFOLDING.md +176 -176
- package/templates/nextblock-template/docs/07-BLOCK-SDK-AND-EXTENSIBILITY.md +146 -146
- package/templates/nextblock-template/docs/10-CUSTOM-BLOCKS.md +222 -222
- package/templates/nextblock-template/docs/11-SELF-HOSTED-DOCKER.md +173 -173
- package/templates/nextblock-template/docs/12-VERCEL-DEPLOYMENT.md +170 -170
- package/templates/nextblock-template/docs/13-STAYING-UP-TO-DATE.md +151 -151
- package/templates/nextblock-template/docs/README.md +39 -39
- package/templates/nextblock-template/hooks/use-hotkeys.ts +21 -21
- package/templates/nextblock-template/hooks/useGlobalSearch.ts +101 -101
- package/templates/nextblock-template/index.d.ts +7 -7
- package/templates/nextblock-template/lib/app-secrets.ts +39 -39
- package/templates/nextblock-template/lib/auth/cookies.ts +47 -47
- package/templates/nextblock-template/lib/auth/crypto.ts +45 -45
- package/templates/nextblock-template/lib/auth/trustedDevices.ts +92 -92
- package/templates/nextblock-template/lib/auth-redirects.ts +46 -46
- package/templates/nextblock-template/lib/blocks/README.md +13 -13
- package/templates/nextblock-template/lib/botProtection/verify.ts +134 -134
- package/templates/nextblock-template/lib/cms-transfer/server.ts +2243 -2243
- package/templates/nextblock-template/lib/cms-transfer/types.ts +145 -145
- package/templates/nextblock-template/lib/custom-block-definitions.ts +87 -87
- package/templates/nextblock-template/lib/custom-block-r2-upload-shared.ts +178 -178
- package/templates/nextblock-template/lib/custom-block-r2-upload.test.ts +140 -140
- package/templates/nextblock-template/lib/custom-block-r2-upload.ts +88 -88
- package/templates/nextblock-template/lib/custom-block-relations.test.ts +227 -227
- package/templates/nextblock-template/lib/custom-block-relations.ts +279 -279
- package/templates/nextblock-template/lib/custom-block-safelist.ts +14 -14
- package/templates/nextblock-template/lib/editor/dynamic-extension-core.test.ts +172 -172
- package/templates/nextblock-template/lib/editor/dynamic-extension-core.ts +213 -213
- package/templates/nextblock-template/lib/editor/dynamic-extension-loader.ts +22 -22
- package/templates/nextblock-template/lib/editor/dynamic-extensions.tsx +193 -193
- package/templates/nextblock-template/lib/email/branding-format.test.ts +133 -133
- package/templates/nextblock-template/lib/email/branding-format.ts +123 -123
- package/templates/nextblock-template/lib/email/branding.ts +76 -76
- package/templates/nextblock-template/lib/full-backup/manifest.test.ts +121 -121
- package/templates/nextblock-template/lib/full-backup/manifest.ts +206 -206
- package/templates/nextblock-template/lib/full-backup/server.ts +743 -743
- package/templates/nextblock-template/lib/logos/active-logo.ts +53 -53
- package/templates/nextblock-template/lib/media/resolveMediaUrl.ts +54 -54
- package/templates/nextblock-template/lib/media/youtube.ts +99 -99
- package/templates/nextblock-template/lib/onboarding/actions.ts +31 -31
- package/templates/nextblock-template/lib/onboarding/status.ts +222 -222
- package/templates/nextblock-template/lib/posts/readTime.ts +60 -60
- package/templates/nextblock-template/lib/privacy/consent-client.ts +57 -57
- package/templates/nextblock-template/lib/privacy/contact-emails.ts +64 -64
- package/templates/nextblock-template/lib/privacy/settings.ts +115 -115
- package/templates/nextblock-template/lib/privacy/types.ts +69 -69
- package/templates/nextblock-template/lib/promotions/server.test.ts +74 -74
- package/templates/nextblock-template/lib/promotions/server.ts +741 -741
- package/templates/nextblock-template/lib/resolve-block-relations.test.ts +142 -142
- package/templates/nextblock-template/lib/resolve-block-relations.ts +255 -255
- package/templates/nextblock-template/lib/search/types.ts +27 -27
- package/templates/nextblock-template/lib/setup/actions.ts +460 -460
- package/templates/nextblock-template/lib/setup/env-status.ts +125 -125
- package/templates/nextblock-template/lib/setup/env-write.ts +111 -111
- package/templates/nextblock-template/lib/setup/migrations-bundle.ts +87 -87
- package/templates/nextblock-template/lib/setup/provisioning.ts +59 -59
- package/templates/nextblock-template/lib/setup/schema-apply.ts +408 -408
- package/templates/nextblock-template/lib/setup/system-config.ts +105 -105
- package/templates/nextblock-template/lib/setup/types.ts +18 -18
- package/templates/nextblock-template/lib/site-url.ts +48 -48
- package/templates/nextblock-template/lib/storage/provider.ts +66 -66
- package/templates/nextblock-template/lib/storage/supabase-storage.ts +103 -103
- package/templates/nextblock-template/lib/updates/check-upstream.ts +441 -441
- package/templates/nextblock-template/lib/updates/github-device.ts +206 -206
- package/templates/nextblock-template/lib/updates/repo-identity.ts +56 -56
- package/templates/nextblock-template/next-env.d.ts +1 -0
- package/templates/nextblock-template/package.json +6 -6
- package/templates/nextblock-template/postcss.config.js +6 -6
- package/templates/nextblock-template/scripts/backup.js +115 -115
- package/templates/nextblock-template/scripts/docker-setup.mjs +310 -310
- package/templates/nextblock-template/scripts/restore.js +385 -385
- package/templates/nextblock-template/scripts/verify-cortex-ai-build-widget.tsx +98 -98
- package/templates/nextblock-template/scripts/verify-cortex-ai-generate-blocks.ts +62 -62
- package/templates/nextblock-template/scripts/verify-cortex-ai-global-tools.ts +537 -537
- package/templates/nextblock-template/scripts/verify-cortex-ai-routing.ts +58 -58
- package/templates/nextblock-template/scripts/verify-custom-block-definitions.ts +188 -188
- package/templates/nextblock-template/scripts/verify-dynamic-custom-block-extensions.ts +123 -123
- package/templates/nextblock-template/scripts/verify-dynamic-layout-engine.tsx +133 -133
- package/templates/nextblock-template/scripts/verify-milestone-2-custom-blocks.ts +65 -65
- package/templates/nextblock-template/tailwind.config.js +25 -25
- package/templates/nextblock-template/tools/build-migrate.mjs +209 -209
- package/templates/nextblock-template/tools/configure-supabase-auth.js +282 -282
- package/templates/nextblock-template/tsconfig.tsbuildinfo +1 -1
- package/templates/nextblock-template/types/jsdom.d.ts +6 -6
- package/tsconfig.base.json +3 -3
|
@@ -1,456 +1,456 @@
|
|
|
1
|
-
"use client";
|
|
2
|
-
|
|
3
|
-
import { useRef, useState, useTransition } from "react";
|
|
4
|
-
import { UploadCloud } from "lucide-react";
|
|
5
|
-
import {
|
|
6
|
-
Alert,
|
|
7
|
-
AlertDescription,
|
|
8
|
-
AlertTitle,
|
|
9
|
-
Button,
|
|
10
|
-
Card,
|
|
11
|
-
CardContent,
|
|
12
|
-
CardDescription,
|
|
13
|
-
CardHeader,
|
|
14
|
-
CardTitle,
|
|
15
|
-
} from "@nextblock-cms/ui";
|
|
16
|
-
|
|
17
|
-
import {
|
|
18
|
-
applyPromotionsImportAction,
|
|
19
|
-
dryRunPromotionsImportAction,
|
|
20
|
-
exportPromotionsAction,
|
|
21
|
-
getPromotionsTemplateAction,
|
|
22
|
-
} from "./actions";
|
|
23
|
-
import type {
|
|
24
|
-
PromotionImportSummary,
|
|
25
|
-
PromotionKind,
|
|
26
|
-
PromotionTransferResult,
|
|
27
|
-
} from "../../../lib/promotions/server";
|
|
28
|
-
|
|
29
|
-
interface PromotionsWorkspaceProps {
|
|
30
|
-
isEcommerceActive: boolean;
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
const TABS: Array<{ kind: PromotionKind; label: string; description: string }> = [
|
|
34
|
-
{
|
|
35
|
-
kind: "sale",
|
|
36
|
-
label: "Promotions (Sales)",
|
|
37
|
-
description:
|
|
38
|
-
"Bulk-schedule sale prices with a start/end window. Enter a SKU (it matches products and variations), and a price as a single number (e.g. 14.99) or a currency map (e.g. {\"USD\":14.99,\"EUR\":13.5}). <br>Dates are optional — leave them empty for an always-on sale; a date with no time is inclusive (start of day to end of day). <br>Sales switch on/off automatically, and Freemius products get a matching time-bounded coupon.",
|
|
39
|
-
},
|
|
40
|
-
{
|
|
41
|
-
kind: "price_change",
|
|
42
|
-
label: "Price Changes",
|
|
43
|
-
description:
|
|
44
|
-
"Bulk-schedule a permanent regular-price change that takes effect from a date. Enter a SKU and a new price (single number or currency map). <br>Intended for Stripe/physical products Freemius regular prices are owned by Freemius and are skipped.",
|
|
45
|
-
},
|
|
46
|
-
];
|
|
47
|
-
|
|
48
|
-
function readFileAsText(file: File) {
|
|
49
|
-
return new Promise<string>((resolve, reject) => {
|
|
50
|
-
const reader = new FileReader();
|
|
51
|
-
reader.onload = () => resolve(String(reader.result || ""));
|
|
52
|
-
reader.onerror = () => reject(reader.error);
|
|
53
|
-
reader.readAsText(file);
|
|
54
|
-
});
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
function downloadCsv(result: PromotionTransferResult) {
|
|
58
|
-
if (!result.content || !result.fileName) {
|
|
59
|
-
return;
|
|
60
|
-
}
|
|
61
|
-
const blob = new Blob([result.content], { type: result.mimeType || "text/csv" });
|
|
62
|
-
const url = URL.createObjectURL(blob);
|
|
63
|
-
const link = document.createElement("a");
|
|
64
|
-
link.href = url;
|
|
65
|
-
link.download = result.fileName;
|
|
66
|
-
document.body.appendChild(link);
|
|
67
|
-
link.click();
|
|
68
|
-
document.body.removeChild(link);
|
|
69
|
-
URL.revokeObjectURL(url);
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
export function PromotionsWorkspace({ isEcommerceActive }: PromotionsWorkspaceProps) {
|
|
73
|
-
const [activeKind, setActiveKind] = useState<PromotionKind>("sale");
|
|
74
|
-
const [csvContent, setCsvContent] = useState<string | null>(null);
|
|
75
|
-
const [fileName, setFileName] = useState<string | null>(null);
|
|
76
|
-
const [summary, setSummary] = useState<PromotionImportSummary | null>(null);
|
|
77
|
-
const [statusMessage, setStatusMessage] = useState<string | null>(null);
|
|
78
|
-
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
|
79
|
-
const [isPending, startTransition] = useTransition();
|
|
80
|
-
const [isDragActive, setIsDragActive] = useState(false);
|
|
81
|
-
const fileInputRef = useRef<HTMLInputElement>(null);
|
|
82
|
-
|
|
83
|
-
const activeTab = TABS.find((tab) => tab.kind === activeKind) ?? TABS[0];
|
|
84
|
-
|
|
85
|
-
function resetImportState() {
|
|
86
|
-
setCsvContent(null);
|
|
87
|
-
setFileName(null);
|
|
88
|
-
setSummary(null);
|
|
89
|
-
setStatusMessage(null);
|
|
90
|
-
setErrorMessage(null);
|
|
91
|
-
if (fileInputRef.current) {
|
|
92
|
-
fileInputRef.current.value = "";
|
|
93
|
-
}
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
function switchKind(kind: PromotionKind) {
|
|
97
|
-
setActiveKind(kind);
|
|
98
|
-
resetImportState();
|
|
99
|
-
}
|
|
100
|
-
|
|
101
|
-
async function processFile(file: File | null) {
|
|
102
|
-
setSummary(null);
|
|
103
|
-
setStatusMessage(null);
|
|
104
|
-
setErrorMessage(null);
|
|
105
|
-
if (!file) {
|
|
106
|
-
setCsvContent(null);
|
|
107
|
-
setFileName(null);
|
|
108
|
-
return;
|
|
109
|
-
}
|
|
110
|
-
const isCsv =
|
|
111
|
-
file.type === "text/csv" ||
|
|
112
|
-
file.type === "application/vnd.ms-excel" ||
|
|
113
|
-
file.name.toLowerCase().endsWith(".csv");
|
|
114
|
-
if (!isCsv) {
|
|
115
|
-
setCsvContent(null);
|
|
116
|
-
setFileName(null);
|
|
117
|
-
setErrorMessage("Please choose a .csv file.");
|
|
118
|
-
return;
|
|
119
|
-
}
|
|
120
|
-
try {
|
|
121
|
-
const text = await readFileAsText(file);
|
|
122
|
-
setCsvContent(text);
|
|
123
|
-
setFileName(file.name);
|
|
124
|
-
} catch {
|
|
125
|
-
setErrorMessage("Could not read the selected file.");
|
|
126
|
-
}
|
|
127
|
-
}
|
|
128
|
-
|
|
129
|
-
function handleFileChange(event: React.ChangeEvent<HTMLInputElement>) {
|
|
130
|
-
void processFile(event.target.files?.[0] ?? null);
|
|
131
|
-
}
|
|
132
|
-
|
|
133
|
-
function handleDrop(event: React.DragEvent<HTMLDivElement>) {
|
|
134
|
-
event.preventDefault();
|
|
135
|
-
setIsDragActive(false);
|
|
136
|
-
void processFile(event.dataTransfer.files?.[0] ?? null);
|
|
137
|
-
}
|
|
138
|
-
|
|
139
|
-
function handleTemplate() {
|
|
140
|
-
startTransition(async () => {
|
|
141
|
-
const result = await getPromotionsTemplateAction(activeKind);
|
|
142
|
-
if (!result.success) {
|
|
143
|
-
setErrorMessage(result.error || "Failed to build template.");
|
|
144
|
-
return;
|
|
145
|
-
}
|
|
146
|
-
downloadCsv(result);
|
|
147
|
-
});
|
|
148
|
-
}
|
|
149
|
-
|
|
150
|
-
function handleExport() {
|
|
151
|
-
startTransition(async () => {
|
|
152
|
-
const result = await exportPromotionsAction(activeKind);
|
|
153
|
-
if (!result.success) {
|
|
154
|
-
setErrorMessage(result.error || "Failed to export CSV.");
|
|
155
|
-
return;
|
|
156
|
-
}
|
|
157
|
-
downloadCsv(result);
|
|
158
|
-
});
|
|
159
|
-
}
|
|
160
|
-
|
|
161
|
-
function handleDryRun() {
|
|
162
|
-
if (!csvContent) {
|
|
163
|
-
return;
|
|
164
|
-
}
|
|
165
|
-
setErrorMessage(null);
|
|
166
|
-
setStatusMessage(null);
|
|
167
|
-
startTransition(async () => {
|
|
168
|
-
const result = await dryRunPromotionsImportAction({
|
|
169
|
-
kind: activeKind,
|
|
170
|
-
content: csvContent,
|
|
171
|
-
});
|
|
172
|
-
if (!result.success || !result.summary) {
|
|
173
|
-
setErrorMessage(result.error || "Validation failed.");
|
|
174
|
-
return;
|
|
175
|
-
}
|
|
176
|
-
setSummary(result.summary);
|
|
177
|
-
setStatusMessage(
|
|
178
|
-
result.summary.errors.length === 0
|
|
179
|
-
? "Validation passed. Review the preview, then apply."
|
|
180
|
-
: "Validation found errors. Fix them and re-validate."
|
|
181
|
-
);
|
|
182
|
-
});
|
|
183
|
-
}
|
|
184
|
-
|
|
185
|
-
function handleApply() {
|
|
186
|
-
if (!csvContent) {
|
|
187
|
-
return;
|
|
188
|
-
}
|
|
189
|
-
setErrorMessage(null);
|
|
190
|
-
setStatusMessage(null);
|
|
191
|
-
startTransition(async () => {
|
|
192
|
-
const result = await applyPromotionsImportAction({
|
|
193
|
-
kind: activeKind,
|
|
194
|
-
content: csvContent,
|
|
195
|
-
});
|
|
196
|
-
if (!result.success || !result.summary) {
|
|
197
|
-
setErrorMessage(result.error || "Import failed.");
|
|
198
|
-
return;
|
|
199
|
-
}
|
|
200
|
-
setSummary(result.summary);
|
|
201
|
-
setStatusMessage(
|
|
202
|
-
result.summary.errors.length === 0
|
|
203
|
-
? `Applied ${result.summary.applied} row(s).${
|
|
204
|
-
result.summary.skipped ? ` Skipped ${result.summary.skipped}.` : ""
|
|
205
|
-
}`
|
|
206
|
-
: "Import aborted because of validation errors. No changes were made."
|
|
207
|
-
);
|
|
208
|
-
});
|
|
209
|
-
}
|
|
210
|
-
|
|
211
|
-
const canApply = Boolean(summary && summary.errors.length === 0 && csvContent);
|
|
212
|
-
|
|
213
|
-
return (
|
|
214
|
-
<div className="space-y-6 p-4 sm:p-6">
|
|
215
|
-
<div className="space-y-1">
|
|
216
|
-
<h1 className="text-2xl font-bold tracking-tight">Bulk Price & Sales</h1>
|
|
217
|
-
<p className="text-sm text-muted-foreground">
|
|
218
|
-
Bulk-schedule sale prices and regular-price changes across many products
|
|
219
|
-
via CSV. Schedules take effect at read time — there is nothing to run.
|
|
220
|
-
</p>
|
|
221
|
-
</div>
|
|
222
|
-
|
|
223
|
-
{!isEcommerceActive ? (
|
|
224
|
-
<Alert variant="destructive">
|
|
225
|
-
<AlertTitle>Ecommerce package inactive</AlertTitle>
|
|
226
|
-
<AlertDescription>
|
|
227
|
-
Promotions require an active ecommerce license. Activate it in Packages
|
|
228
|
-
to manage scheduled pricing.
|
|
229
|
-
</AlertDescription>
|
|
230
|
-
</Alert>
|
|
231
|
-
) : null}
|
|
232
|
-
|
|
233
|
-
<div className="flex flex-wrap gap-2 border-b pb-2">
|
|
234
|
-
{TABS.map((tab) => (
|
|
235
|
-
<Button
|
|
236
|
-
key={tab.kind}
|
|
237
|
-
type="button"
|
|
238
|
-
variant={tab.kind === activeKind ? "default" : "outline"}
|
|
239
|
-
size="lg"
|
|
240
|
-
onClick={() => switchKind(tab.kind)}
|
|
241
|
-
>
|
|
242
|
-
{tab.label}
|
|
243
|
-
</Button>
|
|
244
|
-
))}
|
|
245
|
-
</div>
|
|
246
|
-
|
|
247
|
-
<Card>
|
|
248
|
-
<CardHeader>
|
|
249
|
-
<CardTitle>{activeTab.label}</CardTitle>
|
|
250
|
-
<CardDescription dangerouslySetInnerHTML={{ __html: activeTab.description }} />
|
|
251
|
-
</CardHeader>
|
|
252
|
-
<CardContent className="space-y-5">
|
|
253
|
-
<div className="flex flex-wrap gap-2">
|
|
254
|
-
<Button
|
|
255
|
-
type="button"
|
|
256
|
-
variant="outline"
|
|
257
|
-
onClick={handleTemplate}
|
|
258
|
-
disabled={isPending}
|
|
259
|
-
>
|
|
260
|
-
Download template
|
|
261
|
-
</Button>
|
|
262
|
-
<Button
|
|
263
|
-
type="button"
|
|
264
|
-
variant="outline"
|
|
265
|
-
onClick={handleExport}
|
|
266
|
-
disabled={isPending}
|
|
267
|
-
>
|
|
268
|
-
{activeKind === "price_change"
|
|
269
|
-
? "Export current prices"
|
|
270
|
-
: "Export current sales"}
|
|
271
|
-
</Button>
|
|
272
|
-
</div>
|
|
273
|
-
|
|
274
|
-
<div className="space-y-2">
|
|
275
|
-
<label className="text-xs font-bold uppercase tracking-wider text-muted-foreground">
|
|
276
|
-
Upload CSV
|
|
277
|
-
</label>
|
|
278
|
-
{/* Hidden native input; the styled dropzone below triggers it. */}
|
|
279
|
-
<input
|
|
280
|
-
ref={fileInputRef}
|
|
281
|
-
type="file"
|
|
282
|
-
accept=".csv,text/csv"
|
|
283
|
-
onChange={handleFileChange}
|
|
284
|
-
className="hidden"
|
|
285
|
-
/>
|
|
286
|
-
<div
|
|
287
|
-
role="button"
|
|
288
|
-
tabIndex={0}
|
|
289
|
-
aria-label="Upload a CSV file by clicking or dragging it here"
|
|
290
|
-
onClick={() => fileInputRef.current?.click()}
|
|
291
|
-
onKeyDown={(event) => {
|
|
292
|
-
if (event.key === "Enter" || event.key === " ") {
|
|
293
|
-
event.preventDefault();
|
|
294
|
-
fileInputRef.current?.click();
|
|
295
|
-
}
|
|
296
|
-
}}
|
|
297
|
-
onDragOver={(event) => {
|
|
298
|
-
event.preventDefault();
|
|
299
|
-
setIsDragActive(true);
|
|
300
|
-
}}
|
|
301
|
-
onDragEnter={(event) => {
|
|
302
|
-
event.preventDefault();
|
|
303
|
-
setIsDragActive(true);
|
|
304
|
-
}}
|
|
305
|
-
onDragLeave={(event) => {
|
|
306
|
-
event.preventDefault();
|
|
307
|
-
setIsDragActive(false);
|
|
308
|
-
}}
|
|
309
|
-
onDrop={handleDrop}
|
|
310
|
-
className={`flex cursor-pointer flex-col items-center justify-center gap-2 rounded-lg border-2 border-dashed px-6 py-8 text-center transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-2 ${
|
|
311
|
-
isDragActive
|
|
312
|
-
? "border-primary bg-primary/10"
|
|
313
|
-
: "border-muted-foreground/25 hover:border-primary/50 hover:bg-muted/40"
|
|
314
|
-
}`}
|
|
315
|
-
>
|
|
316
|
-
<div className="pointer-events-none flex flex-col items-center gap-2">
|
|
317
|
-
<UploadCloud
|
|
318
|
-
className={`h-8 w-8 ${isDragActive ? "text-primary" : "text-muted-foreground"}`}
|
|
319
|
-
/>
|
|
320
|
-
{fileName ? (
|
|
321
|
-
<div className="space-y-0.5">
|
|
322
|
-
<p className="text-sm font-medium text-foreground">{fileName}</p>
|
|
323
|
-
<p className="text-xs text-muted-foreground">Click or drop to replace</p>
|
|
324
|
-
</div>
|
|
325
|
-
) : (
|
|
326
|
-
<div className="space-y-0.5">
|
|
327
|
-
<p className="text-sm font-medium text-foreground">
|
|
328
|
-
Drag & drop your CSV here, or{" "}
|
|
329
|
-
<span className="text-primary underline">browse</span>
|
|
330
|
-
</p>
|
|
331
|
-
<p className="text-xs text-muted-foreground">.csv files only</p>
|
|
332
|
-
</div>
|
|
333
|
-
)}
|
|
334
|
-
</div>
|
|
335
|
-
</div>
|
|
336
|
-
{fileName ? (
|
|
337
|
-
<button
|
|
338
|
-
type="button"
|
|
339
|
-
onClick={resetImportState}
|
|
340
|
-
className="text-xs text-muted-foreground underline-offset-2 hover:text-destructive hover:underline"
|
|
341
|
-
>
|
|
342
|
-
Remove file
|
|
343
|
-
</button>
|
|
344
|
-
) : null}
|
|
345
|
-
</div>
|
|
346
|
-
|
|
347
|
-
<div className="flex flex-wrap gap-2">
|
|
348
|
-
<Button
|
|
349
|
-
type="button"
|
|
350
|
-
size="sm"
|
|
351
|
-
variant="outline"
|
|
352
|
-
onClick={handleDryRun}
|
|
353
|
-
disabled={isPending || !csvContent}
|
|
354
|
-
>
|
|
355
|
-
{isPending ? "Working…" : "Validate (dry run)"}
|
|
356
|
-
</Button>
|
|
357
|
-
<Button
|
|
358
|
-
type="button"
|
|
359
|
-
size="sm"
|
|
360
|
-
onClick={handleApply}
|
|
361
|
-
disabled={isPending || !canApply}
|
|
362
|
-
>
|
|
363
|
-
Apply import
|
|
364
|
-
</Button>
|
|
365
|
-
</div>
|
|
366
|
-
|
|
367
|
-
{errorMessage ? (
|
|
368
|
-
<Alert variant="destructive">
|
|
369
|
-
<AlertTitle>Error</AlertTitle>
|
|
370
|
-
<AlertDescription>{errorMessage}</AlertDescription>
|
|
371
|
-
</Alert>
|
|
372
|
-
) : null}
|
|
373
|
-
|
|
374
|
-
{statusMessage ? (
|
|
375
|
-
<Alert>
|
|
376
|
-
<AlertTitle>Status</AlertTitle>
|
|
377
|
-
<AlertDescription>{statusMessage}</AlertDescription>
|
|
378
|
-
</Alert>
|
|
379
|
-
) : null}
|
|
380
|
-
|
|
381
|
-
{summary ? (
|
|
382
|
-
<div className="space-y-3 rounded-md border p-3">
|
|
383
|
-
<div className="flex flex-wrap gap-4 text-sm">
|
|
384
|
-
<span>
|
|
385
|
-
<strong>{summary.totalRows}</strong> rows
|
|
386
|
-
</span>
|
|
387
|
-
<span>
|
|
388
|
-
<strong>{summary.applied}</strong> applied
|
|
389
|
-
</span>
|
|
390
|
-
<span>
|
|
391
|
-
<strong>{summary.skipped}</strong> skipped
|
|
392
|
-
</span>
|
|
393
|
-
<span className="text-destructive">
|
|
394
|
-
<strong>{summary.errors.length}</strong> errors
|
|
395
|
-
</span>
|
|
396
|
-
<span className="text-amber-600">
|
|
397
|
-
<strong>{summary.warnings.length}</strong> warnings
|
|
398
|
-
</span>
|
|
399
|
-
</div>
|
|
400
|
-
|
|
401
|
-
{summary.errors.length > 0 ? (
|
|
402
|
-
<div className="space-y-1">
|
|
403
|
-
<p className="text-xs font-bold uppercase tracking-wider text-destructive">
|
|
404
|
-
Errors
|
|
405
|
-
</p>
|
|
406
|
-
<ul className="space-y-0.5 text-xs text-destructive">
|
|
407
|
-
{summary.errors.map((message, index) => (
|
|
408
|
-
<li key={`error-${index}`}>
|
|
409
|
-
Row {message.row}: {message.message}
|
|
410
|
-
</li>
|
|
411
|
-
))}
|
|
412
|
-
</ul>
|
|
413
|
-
</div>
|
|
414
|
-
) : null}
|
|
415
|
-
|
|
416
|
-
{summary.warnings.length > 0 ? (
|
|
417
|
-
<div className="space-y-1">
|
|
418
|
-
<p className="text-xs font-bold uppercase tracking-wider text-amber-600">
|
|
419
|
-
Warnings
|
|
420
|
-
</p>
|
|
421
|
-
<ul className="space-y-0.5 text-xs text-amber-600">
|
|
422
|
-
{summary.warnings.map((message, index) => (
|
|
423
|
-
<li key={`warning-${index}`}>
|
|
424
|
-
Row {message.row}: {message.message}
|
|
425
|
-
</li>
|
|
426
|
-
))}
|
|
427
|
-
</ul>
|
|
428
|
-
</div>
|
|
429
|
-
) : null}
|
|
430
|
-
|
|
431
|
-
{summary.preview.length > 0 ? (
|
|
432
|
-
<div className="space-y-1">
|
|
433
|
-
<p className="text-xs font-bold uppercase tracking-wider text-muted-foreground">
|
|
434
|
-
Preview
|
|
435
|
-
</p>
|
|
436
|
-
<ul className="space-y-0.5 text-xs text-muted-foreground">
|
|
437
|
-
{summary.preview.slice(0, 50).map((item, index) => (
|
|
438
|
-
<li key={`preview-${index}`}>
|
|
439
|
-
Row {item.row}: {item.action} {item.identifier} ({item.matched} matched)
|
|
440
|
-
</li>
|
|
441
|
-
))}
|
|
442
|
-
</ul>
|
|
443
|
-
{summary.preview.length > 50 ? (
|
|
444
|
-
<p className="text-[11px] text-muted-foreground">
|
|
445
|
-
Showing first 50 of {summary.preview.length} rows.
|
|
446
|
-
</p>
|
|
447
|
-
) : null}
|
|
448
|
-
</div>
|
|
449
|
-
) : null}
|
|
450
|
-
</div>
|
|
451
|
-
) : null}
|
|
452
|
-
</CardContent>
|
|
453
|
-
</Card>
|
|
454
|
-
</div>
|
|
455
|
-
);
|
|
456
|
-
}
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useRef, useState, useTransition } from "react";
|
|
4
|
+
import { UploadCloud } from "lucide-react";
|
|
5
|
+
import {
|
|
6
|
+
Alert,
|
|
7
|
+
AlertDescription,
|
|
8
|
+
AlertTitle,
|
|
9
|
+
Button,
|
|
10
|
+
Card,
|
|
11
|
+
CardContent,
|
|
12
|
+
CardDescription,
|
|
13
|
+
CardHeader,
|
|
14
|
+
CardTitle,
|
|
15
|
+
} from "@nextblock-cms/ui";
|
|
16
|
+
|
|
17
|
+
import {
|
|
18
|
+
applyPromotionsImportAction,
|
|
19
|
+
dryRunPromotionsImportAction,
|
|
20
|
+
exportPromotionsAction,
|
|
21
|
+
getPromotionsTemplateAction,
|
|
22
|
+
} from "./actions";
|
|
23
|
+
import type {
|
|
24
|
+
PromotionImportSummary,
|
|
25
|
+
PromotionKind,
|
|
26
|
+
PromotionTransferResult,
|
|
27
|
+
} from "../../../lib/promotions/server";
|
|
28
|
+
|
|
29
|
+
interface PromotionsWorkspaceProps {
|
|
30
|
+
isEcommerceActive: boolean;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const TABS: Array<{ kind: PromotionKind; label: string; description: string }> = [
|
|
34
|
+
{
|
|
35
|
+
kind: "sale",
|
|
36
|
+
label: "Promotions (Sales)",
|
|
37
|
+
description:
|
|
38
|
+
"Bulk-schedule sale prices with a start/end window. Enter a SKU (it matches products and variations), and a price as a single number (e.g. 14.99) or a currency map (e.g. {\"USD\":14.99,\"EUR\":13.5}). <br>Dates are optional — leave them empty for an always-on sale; a date with no time is inclusive (start of day to end of day). <br>Sales switch on/off automatically, and Freemius products get a matching time-bounded coupon.",
|
|
39
|
+
},
|
|
40
|
+
{
|
|
41
|
+
kind: "price_change",
|
|
42
|
+
label: "Price Changes",
|
|
43
|
+
description:
|
|
44
|
+
"Bulk-schedule a permanent regular-price change that takes effect from a date. Enter a SKU and a new price (single number or currency map). <br>Intended for Stripe/physical products Freemius regular prices are owned by Freemius and are skipped.",
|
|
45
|
+
},
|
|
46
|
+
];
|
|
47
|
+
|
|
48
|
+
function readFileAsText(file: File) {
|
|
49
|
+
return new Promise<string>((resolve, reject) => {
|
|
50
|
+
const reader = new FileReader();
|
|
51
|
+
reader.onload = () => resolve(String(reader.result || ""));
|
|
52
|
+
reader.onerror = () => reject(reader.error);
|
|
53
|
+
reader.readAsText(file);
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function downloadCsv(result: PromotionTransferResult) {
|
|
58
|
+
if (!result.content || !result.fileName) {
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
const blob = new Blob([result.content], { type: result.mimeType || "text/csv" });
|
|
62
|
+
const url = URL.createObjectURL(blob);
|
|
63
|
+
const link = document.createElement("a");
|
|
64
|
+
link.href = url;
|
|
65
|
+
link.download = result.fileName;
|
|
66
|
+
document.body.appendChild(link);
|
|
67
|
+
link.click();
|
|
68
|
+
document.body.removeChild(link);
|
|
69
|
+
URL.revokeObjectURL(url);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function PromotionsWorkspace({ isEcommerceActive }: PromotionsWorkspaceProps) {
|
|
73
|
+
const [activeKind, setActiveKind] = useState<PromotionKind>("sale");
|
|
74
|
+
const [csvContent, setCsvContent] = useState<string | null>(null);
|
|
75
|
+
const [fileName, setFileName] = useState<string | null>(null);
|
|
76
|
+
const [summary, setSummary] = useState<PromotionImportSummary | null>(null);
|
|
77
|
+
const [statusMessage, setStatusMessage] = useState<string | null>(null);
|
|
78
|
+
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
|
79
|
+
const [isPending, startTransition] = useTransition();
|
|
80
|
+
const [isDragActive, setIsDragActive] = useState(false);
|
|
81
|
+
const fileInputRef = useRef<HTMLInputElement>(null);
|
|
82
|
+
|
|
83
|
+
const activeTab = TABS.find((tab) => tab.kind === activeKind) ?? TABS[0];
|
|
84
|
+
|
|
85
|
+
function resetImportState() {
|
|
86
|
+
setCsvContent(null);
|
|
87
|
+
setFileName(null);
|
|
88
|
+
setSummary(null);
|
|
89
|
+
setStatusMessage(null);
|
|
90
|
+
setErrorMessage(null);
|
|
91
|
+
if (fileInputRef.current) {
|
|
92
|
+
fileInputRef.current.value = "";
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function switchKind(kind: PromotionKind) {
|
|
97
|
+
setActiveKind(kind);
|
|
98
|
+
resetImportState();
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
async function processFile(file: File | null) {
|
|
102
|
+
setSummary(null);
|
|
103
|
+
setStatusMessage(null);
|
|
104
|
+
setErrorMessage(null);
|
|
105
|
+
if (!file) {
|
|
106
|
+
setCsvContent(null);
|
|
107
|
+
setFileName(null);
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
const isCsv =
|
|
111
|
+
file.type === "text/csv" ||
|
|
112
|
+
file.type === "application/vnd.ms-excel" ||
|
|
113
|
+
file.name.toLowerCase().endsWith(".csv");
|
|
114
|
+
if (!isCsv) {
|
|
115
|
+
setCsvContent(null);
|
|
116
|
+
setFileName(null);
|
|
117
|
+
setErrorMessage("Please choose a .csv file.");
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
try {
|
|
121
|
+
const text = await readFileAsText(file);
|
|
122
|
+
setCsvContent(text);
|
|
123
|
+
setFileName(file.name);
|
|
124
|
+
} catch {
|
|
125
|
+
setErrorMessage("Could not read the selected file.");
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function handleFileChange(event: React.ChangeEvent<HTMLInputElement>) {
|
|
130
|
+
void processFile(event.target.files?.[0] ?? null);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function handleDrop(event: React.DragEvent<HTMLDivElement>) {
|
|
134
|
+
event.preventDefault();
|
|
135
|
+
setIsDragActive(false);
|
|
136
|
+
void processFile(event.dataTransfer.files?.[0] ?? null);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function handleTemplate() {
|
|
140
|
+
startTransition(async () => {
|
|
141
|
+
const result = await getPromotionsTemplateAction(activeKind);
|
|
142
|
+
if (!result.success) {
|
|
143
|
+
setErrorMessage(result.error || "Failed to build template.");
|
|
144
|
+
return;
|
|
145
|
+
}
|
|
146
|
+
downloadCsv(result);
|
|
147
|
+
});
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function handleExport() {
|
|
151
|
+
startTransition(async () => {
|
|
152
|
+
const result = await exportPromotionsAction(activeKind);
|
|
153
|
+
if (!result.success) {
|
|
154
|
+
setErrorMessage(result.error || "Failed to export CSV.");
|
|
155
|
+
return;
|
|
156
|
+
}
|
|
157
|
+
downloadCsv(result);
|
|
158
|
+
});
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function handleDryRun() {
|
|
162
|
+
if (!csvContent) {
|
|
163
|
+
return;
|
|
164
|
+
}
|
|
165
|
+
setErrorMessage(null);
|
|
166
|
+
setStatusMessage(null);
|
|
167
|
+
startTransition(async () => {
|
|
168
|
+
const result = await dryRunPromotionsImportAction({
|
|
169
|
+
kind: activeKind,
|
|
170
|
+
content: csvContent,
|
|
171
|
+
});
|
|
172
|
+
if (!result.success || !result.summary) {
|
|
173
|
+
setErrorMessage(result.error || "Validation failed.");
|
|
174
|
+
return;
|
|
175
|
+
}
|
|
176
|
+
setSummary(result.summary);
|
|
177
|
+
setStatusMessage(
|
|
178
|
+
result.summary.errors.length === 0
|
|
179
|
+
? "Validation passed. Review the preview, then apply."
|
|
180
|
+
: "Validation found errors. Fix them and re-validate."
|
|
181
|
+
);
|
|
182
|
+
});
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function handleApply() {
|
|
186
|
+
if (!csvContent) {
|
|
187
|
+
return;
|
|
188
|
+
}
|
|
189
|
+
setErrorMessage(null);
|
|
190
|
+
setStatusMessage(null);
|
|
191
|
+
startTransition(async () => {
|
|
192
|
+
const result = await applyPromotionsImportAction({
|
|
193
|
+
kind: activeKind,
|
|
194
|
+
content: csvContent,
|
|
195
|
+
});
|
|
196
|
+
if (!result.success || !result.summary) {
|
|
197
|
+
setErrorMessage(result.error || "Import failed.");
|
|
198
|
+
return;
|
|
199
|
+
}
|
|
200
|
+
setSummary(result.summary);
|
|
201
|
+
setStatusMessage(
|
|
202
|
+
result.summary.errors.length === 0
|
|
203
|
+
? `Applied ${result.summary.applied} row(s).${
|
|
204
|
+
result.summary.skipped ? ` Skipped ${result.summary.skipped}.` : ""
|
|
205
|
+
}`
|
|
206
|
+
: "Import aborted because of validation errors. No changes were made."
|
|
207
|
+
);
|
|
208
|
+
});
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
const canApply = Boolean(summary && summary.errors.length === 0 && csvContent);
|
|
212
|
+
|
|
213
|
+
return (
|
|
214
|
+
<div className="space-y-6 p-4 sm:p-6">
|
|
215
|
+
<div className="space-y-1">
|
|
216
|
+
<h1 className="text-2xl font-bold tracking-tight">Bulk Price & Sales</h1>
|
|
217
|
+
<p className="text-sm text-muted-foreground">
|
|
218
|
+
Bulk-schedule sale prices and regular-price changes across many products
|
|
219
|
+
via CSV. Schedules take effect at read time — there is nothing to run.
|
|
220
|
+
</p>
|
|
221
|
+
</div>
|
|
222
|
+
|
|
223
|
+
{!isEcommerceActive ? (
|
|
224
|
+
<Alert variant="destructive">
|
|
225
|
+
<AlertTitle>Ecommerce package inactive</AlertTitle>
|
|
226
|
+
<AlertDescription>
|
|
227
|
+
Promotions require an active ecommerce license. Activate it in Packages
|
|
228
|
+
to manage scheduled pricing.
|
|
229
|
+
</AlertDescription>
|
|
230
|
+
</Alert>
|
|
231
|
+
) : null}
|
|
232
|
+
|
|
233
|
+
<div className="flex flex-wrap gap-2 border-b pb-2">
|
|
234
|
+
{TABS.map((tab) => (
|
|
235
|
+
<Button
|
|
236
|
+
key={tab.kind}
|
|
237
|
+
type="button"
|
|
238
|
+
variant={tab.kind === activeKind ? "default" : "outline"}
|
|
239
|
+
size="lg"
|
|
240
|
+
onClick={() => switchKind(tab.kind)}
|
|
241
|
+
>
|
|
242
|
+
{tab.label}
|
|
243
|
+
</Button>
|
|
244
|
+
))}
|
|
245
|
+
</div>
|
|
246
|
+
|
|
247
|
+
<Card>
|
|
248
|
+
<CardHeader>
|
|
249
|
+
<CardTitle>{activeTab.label}</CardTitle>
|
|
250
|
+
<CardDescription dangerouslySetInnerHTML={{ __html: activeTab.description }} />
|
|
251
|
+
</CardHeader>
|
|
252
|
+
<CardContent className="space-y-5">
|
|
253
|
+
<div className="flex flex-wrap gap-2">
|
|
254
|
+
<Button
|
|
255
|
+
type="button"
|
|
256
|
+
variant="outline"
|
|
257
|
+
onClick={handleTemplate}
|
|
258
|
+
disabled={isPending}
|
|
259
|
+
>
|
|
260
|
+
Download template
|
|
261
|
+
</Button>
|
|
262
|
+
<Button
|
|
263
|
+
type="button"
|
|
264
|
+
variant="outline"
|
|
265
|
+
onClick={handleExport}
|
|
266
|
+
disabled={isPending}
|
|
267
|
+
>
|
|
268
|
+
{activeKind === "price_change"
|
|
269
|
+
? "Export current prices"
|
|
270
|
+
: "Export current sales"}
|
|
271
|
+
</Button>
|
|
272
|
+
</div>
|
|
273
|
+
|
|
274
|
+
<div className="space-y-2">
|
|
275
|
+
<label className="text-xs font-bold uppercase tracking-wider text-muted-foreground">
|
|
276
|
+
Upload CSV
|
|
277
|
+
</label>
|
|
278
|
+
{/* Hidden native input; the styled dropzone below triggers it. */}
|
|
279
|
+
<input
|
|
280
|
+
ref={fileInputRef}
|
|
281
|
+
type="file"
|
|
282
|
+
accept=".csv,text/csv"
|
|
283
|
+
onChange={handleFileChange}
|
|
284
|
+
className="hidden"
|
|
285
|
+
/>
|
|
286
|
+
<div
|
|
287
|
+
role="button"
|
|
288
|
+
tabIndex={0}
|
|
289
|
+
aria-label="Upload a CSV file by clicking or dragging it here"
|
|
290
|
+
onClick={() => fileInputRef.current?.click()}
|
|
291
|
+
onKeyDown={(event) => {
|
|
292
|
+
if (event.key === "Enter" || event.key === " ") {
|
|
293
|
+
event.preventDefault();
|
|
294
|
+
fileInputRef.current?.click();
|
|
295
|
+
}
|
|
296
|
+
}}
|
|
297
|
+
onDragOver={(event) => {
|
|
298
|
+
event.preventDefault();
|
|
299
|
+
setIsDragActive(true);
|
|
300
|
+
}}
|
|
301
|
+
onDragEnter={(event) => {
|
|
302
|
+
event.preventDefault();
|
|
303
|
+
setIsDragActive(true);
|
|
304
|
+
}}
|
|
305
|
+
onDragLeave={(event) => {
|
|
306
|
+
event.preventDefault();
|
|
307
|
+
setIsDragActive(false);
|
|
308
|
+
}}
|
|
309
|
+
onDrop={handleDrop}
|
|
310
|
+
className={`flex cursor-pointer flex-col items-center justify-center gap-2 rounded-lg border-2 border-dashed px-6 py-8 text-center transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-2 ${
|
|
311
|
+
isDragActive
|
|
312
|
+
? "border-primary bg-primary/10"
|
|
313
|
+
: "border-muted-foreground/25 hover:border-primary/50 hover:bg-muted/40"
|
|
314
|
+
}`}
|
|
315
|
+
>
|
|
316
|
+
<div className="pointer-events-none flex flex-col items-center gap-2">
|
|
317
|
+
<UploadCloud
|
|
318
|
+
className={`h-8 w-8 ${isDragActive ? "text-primary" : "text-muted-foreground"}`}
|
|
319
|
+
/>
|
|
320
|
+
{fileName ? (
|
|
321
|
+
<div className="space-y-0.5">
|
|
322
|
+
<p className="text-sm font-medium text-foreground">{fileName}</p>
|
|
323
|
+
<p className="text-xs text-muted-foreground">Click or drop to replace</p>
|
|
324
|
+
</div>
|
|
325
|
+
) : (
|
|
326
|
+
<div className="space-y-0.5">
|
|
327
|
+
<p className="text-sm font-medium text-foreground">
|
|
328
|
+
Drag & drop your CSV here, or{" "}
|
|
329
|
+
<span className="text-primary underline">browse</span>
|
|
330
|
+
</p>
|
|
331
|
+
<p className="text-xs text-muted-foreground">.csv files only</p>
|
|
332
|
+
</div>
|
|
333
|
+
)}
|
|
334
|
+
</div>
|
|
335
|
+
</div>
|
|
336
|
+
{fileName ? (
|
|
337
|
+
<button
|
|
338
|
+
type="button"
|
|
339
|
+
onClick={resetImportState}
|
|
340
|
+
className="text-xs text-muted-foreground underline-offset-2 hover:text-destructive hover:underline"
|
|
341
|
+
>
|
|
342
|
+
Remove file
|
|
343
|
+
</button>
|
|
344
|
+
) : null}
|
|
345
|
+
</div>
|
|
346
|
+
|
|
347
|
+
<div className="flex flex-wrap gap-2">
|
|
348
|
+
<Button
|
|
349
|
+
type="button"
|
|
350
|
+
size="sm"
|
|
351
|
+
variant="outline"
|
|
352
|
+
onClick={handleDryRun}
|
|
353
|
+
disabled={isPending || !csvContent}
|
|
354
|
+
>
|
|
355
|
+
{isPending ? "Working…" : "Validate (dry run)"}
|
|
356
|
+
</Button>
|
|
357
|
+
<Button
|
|
358
|
+
type="button"
|
|
359
|
+
size="sm"
|
|
360
|
+
onClick={handleApply}
|
|
361
|
+
disabled={isPending || !canApply}
|
|
362
|
+
>
|
|
363
|
+
Apply import
|
|
364
|
+
</Button>
|
|
365
|
+
</div>
|
|
366
|
+
|
|
367
|
+
{errorMessage ? (
|
|
368
|
+
<Alert variant="destructive">
|
|
369
|
+
<AlertTitle>Error</AlertTitle>
|
|
370
|
+
<AlertDescription>{errorMessage}</AlertDescription>
|
|
371
|
+
</Alert>
|
|
372
|
+
) : null}
|
|
373
|
+
|
|
374
|
+
{statusMessage ? (
|
|
375
|
+
<Alert>
|
|
376
|
+
<AlertTitle>Status</AlertTitle>
|
|
377
|
+
<AlertDescription>{statusMessage}</AlertDescription>
|
|
378
|
+
</Alert>
|
|
379
|
+
) : null}
|
|
380
|
+
|
|
381
|
+
{summary ? (
|
|
382
|
+
<div className="space-y-3 rounded-md border p-3">
|
|
383
|
+
<div className="flex flex-wrap gap-4 text-sm">
|
|
384
|
+
<span>
|
|
385
|
+
<strong>{summary.totalRows}</strong> rows
|
|
386
|
+
</span>
|
|
387
|
+
<span>
|
|
388
|
+
<strong>{summary.applied}</strong> applied
|
|
389
|
+
</span>
|
|
390
|
+
<span>
|
|
391
|
+
<strong>{summary.skipped}</strong> skipped
|
|
392
|
+
</span>
|
|
393
|
+
<span className="text-destructive">
|
|
394
|
+
<strong>{summary.errors.length}</strong> errors
|
|
395
|
+
</span>
|
|
396
|
+
<span className="text-amber-600">
|
|
397
|
+
<strong>{summary.warnings.length}</strong> warnings
|
|
398
|
+
</span>
|
|
399
|
+
</div>
|
|
400
|
+
|
|
401
|
+
{summary.errors.length > 0 ? (
|
|
402
|
+
<div className="space-y-1">
|
|
403
|
+
<p className="text-xs font-bold uppercase tracking-wider text-destructive">
|
|
404
|
+
Errors
|
|
405
|
+
</p>
|
|
406
|
+
<ul className="space-y-0.5 text-xs text-destructive">
|
|
407
|
+
{summary.errors.map((message, index) => (
|
|
408
|
+
<li key={`error-${index}`}>
|
|
409
|
+
Row {message.row}: {message.message}
|
|
410
|
+
</li>
|
|
411
|
+
))}
|
|
412
|
+
</ul>
|
|
413
|
+
</div>
|
|
414
|
+
) : null}
|
|
415
|
+
|
|
416
|
+
{summary.warnings.length > 0 ? (
|
|
417
|
+
<div className="space-y-1">
|
|
418
|
+
<p className="text-xs font-bold uppercase tracking-wider text-amber-600">
|
|
419
|
+
Warnings
|
|
420
|
+
</p>
|
|
421
|
+
<ul className="space-y-0.5 text-xs text-amber-600">
|
|
422
|
+
{summary.warnings.map((message, index) => (
|
|
423
|
+
<li key={`warning-${index}`}>
|
|
424
|
+
Row {message.row}: {message.message}
|
|
425
|
+
</li>
|
|
426
|
+
))}
|
|
427
|
+
</ul>
|
|
428
|
+
</div>
|
|
429
|
+
) : null}
|
|
430
|
+
|
|
431
|
+
{summary.preview.length > 0 ? (
|
|
432
|
+
<div className="space-y-1">
|
|
433
|
+
<p className="text-xs font-bold uppercase tracking-wider text-muted-foreground">
|
|
434
|
+
Preview
|
|
435
|
+
</p>
|
|
436
|
+
<ul className="space-y-0.5 text-xs text-muted-foreground">
|
|
437
|
+
{summary.preview.slice(0, 50).map((item, index) => (
|
|
438
|
+
<li key={`preview-${index}`}>
|
|
439
|
+
Row {item.row}: {item.action} {item.identifier} ({item.matched} matched)
|
|
440
|
+
</li>
|
|
441
|
+
))}
|
|
442
|
+
</ul>
|
|
443
|
+
{summary.preview.length > 50 ? (
|
|
444
|
+
<p className="text-[11px] text-muted-foreground">
|
|
445
|
+
Showing first 50 of {summary.preview.length} rows.
|
|
446
|
+
</p>
|
|
447
|
+
) : null}
|
|
448
|
+
</div>
|
|
449
|
+
) : null}
|
|
450
|
+
</div>
|
|
451
|
+
) : null}
|
|
452
|
+
</CardContent>
|
|
453
|
+
</Card>
|
|
454
|
+
</div>
|
|
455
|
+
);
|
|
456
|
+
}
|