@pushwoosh/smart-components-kit 1.3.4 → 1.3.6

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.
@@ -9,7 +9,7 @@ import { CreateCropDrawer } from './CreateCropDrawer';
9
9
  import { useDeleteMediaFile } from './hooks';
10
10
  import { formatBytes, getCropCaption, getThumbUrl } from './mediaFile.utils';
11
11
  import { useLang } from './polyglot';
12
- import { CropBadge, Dropzone, EmptyState, Grid, HiddenInput, Tile, TileActions, TileImage, UsageLine } from './styled';
12
+ import { CropBadge, Dropzone, EmptyState, Grid, HiddenInput, Tile, TileActions, TileImage, UsageLine, WarningBadge } from './styled';
13
13
  const UPLOAD_PATH = '/api/media/upload';
14
14
  const ALLOWED_IMAGE_EXTENSIONS = ['.jpg', '.jpeg', '.png', '.gif', '.webp'];
15
15
  const isAllowedImage = file => {
@@ -26,7 +26,9 @@ export const MediaStorage = ({
26
26
  page,
27
27
  perPage,
28
28
  onPageChange,
29
- onSelect
29
+ onSelect,
30
+ extensions,
31
+ fileWarning
30
32
  }) => {
31
33
  const lang = useLang();
32
34
  const {
@@ -56,18 +58,21 @@ export const MediaStorage = ({
56
58
  const [isDragOver, setIsDragOver] = useState(false);
57
59
  const [createCropFor, setCreateCropFor] = useState(null);
58
60
  const [pendingDelete, setPendingDelete] = useState(null);
61
+ // Format filtering belongs to the query, not the response: `total` and the page count come
62
+ // from the filtered set, so a client-side filter would paginate over files it then hides.
59
63
  const listAsync = useAsync(async () => {
60
64
  const response = await rpcGateway.mediaFiles.List({
61
65
  application,
62
66
  page,
63
- perPage
67
+ perPage,
68
+ extensions
64
69
  });
65
70
  // protobuf JSON omits empty `repeated` fields, so `files` can be undefined.
66
71
  return {
67
72
  files: response.files ?? [],
68
73
  total: response.total
69
74
  };
70
- }, [rpcGateway, application, page, perPage, reloadToken]);
75
+ }, [rpcGateway, application, page, perPage, extensions, reloadToken]);
71
76
  const usageAsync = useAsync(() => rpcGateway.mediaFiles.GetUsage({
72
77
  application
73
78
  }), [rpcGateway, application, reloadToken, usageToken]);
@@ -103,7 +108,8 @@ export const MediaStorage = ({
103
108
  try {
104
109
  const response = await rpcGateway.mediaFiles.List({
105
110
  application,
106
- parentUuid: file.uuid
111
+ parentUuid: file.uuid,
112
+ extensions
107
113
  });
108
114
  if (cancelled) return;
109
115
  setCropsByUuid(prev => ({
@@ -118,7 +124,7 @@ export const MediaStorage = ({
118
124
  return () => {
119
125
  cancelled = true;
120
126
  };
121
- }, [listAsync.value, rpcGateway, application]);
127
+ }, [listAsync.value, rpcGateway, application, extensions]);
122
128
  const uploadFiles = async selected => {
123
129
  if (selected.length === 0) return;
124
130
  const images = selected.filter(isAllowedImage);
@@ -246,6 +252,7 @@ export const MediaStorage = ({
246
252
  }), !loading && files.length > 0 && _jsx(Grid, {
247
253
  children: files.map(file => {
248
254
  const crops = cropsByUuid[file.uuid] ?? [];
255
+ const warning = fileWarning?.(file);
249
256
  return _jsxs(Tile, {
250
257
  children: [_jsx(TileImage, {
251
258
  src: getThumbUrl(file),
@@ -255,6 +262,9 @@ export const MediaStorage = ({
255
262
  message: 'SMART.MEDIA_STORAGE.CROPS_BADGE',
256
263
  count: crops.length
257
264
  })
265
+ }), warning && _jsx(WarningBadge, {
266
+ title: warning,
267
+ children: warning
258
268
  }), _jsxs(TileActions, {
259
269
  children: [crops.length > 0 ? _jsx(DropdownMenuButton, {
260
270
  color: "secondary",
@@ -11,7 +11,9 @@ export const MediaStoragePaginated = ({
11
11
  application,
12
12
  initialPage = 0,
13
13
  perPage = DEFAULT_PER_PAGE,
14
- onSelect
14
+ onSelect,
15
+ extensions,
16
+ fileWarning
15
17
  }) => {
16
18
  const [page, setPage] = useState(initialPage);
17
19
  return _jsx(MediaStorage, {
@@ -19,6 +21,8 @@ export const MediaStoragePaginated = ({
19
21
  page: page,
20
22
  perPage: perPage,
21
23
  onPageChange: setPage,
22
- onSelect: onSelect
24
+ onSelect: onSelect,
25
+ extensions: extensions,
26
+ fileWarning: fileWarning
23
27
  });
24
28
  };
@@ -2,6 +2,7 @@ export declare const Grid: import("styled-components").StyledComponent<"div", an
2
2
  export declare const Tile: import("styled-components").StyledComponent<"div", any, {}, never>;
3
3
  export declare const TileImage: import("styled-components").StyledComponent<"img", any, {}, never>;
4
4
  export declare const CropBadge: import("styled-components").StyledComponent<"span", any, {}, never>;
5
+ export declare const WarningBadge: import("styled-components").StyledComponent<"span", any, {}, never>;
5
6
  export declare const TileActions: import("styled-components").StyledComponent<"div", any, {}, never>;
6
7
  export declare const EmptyState: import("styled-components").StyledComponent<"div", any, {}, never>;
7
8
  export declare const Dropzone: import("styled-components").StyledComponent<"button", any, {
@@ -17,17 +17,21 @@ export const CropBadge = styled.span.withConfig({
17
17
  displayName: "CropBadge",
18
18
  componentId: "sc-14317rj-3"
19
19
  })(["position:absolute;top:6px;right:6px;font-size:10px;font-weight:", ";line-height:1;color:", ";background:", ";border-radius:20px;padding:3px 7px;pointer-events:none;"], FontWeight.BOLD, Color.CLEAR, Color.PRIMARY);
20
+ export const WarningBadge = styled.span.withConfig({
21
+ displayName: "WarningBadge",
22
+ componentId: "sc-14317rj-4"
23
+ })(["position:absolute;bottom:0;left:0;right:0;font-size:10px;line-height:1.2;text-align:center;color:", ";background:", ";padding:3px 6px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;pointer-events:none;"], Color.CLEAR, Color.WARNING);
20
24
  export const TileActions = styled.div.withConfig({
21
25
  displayName: "TileActions",
22
- componentId: "sc-14317rj-4"
26
+ componentId: "sc-14317rj-5"
23
27
  })(["position:absolute;top:6px;left:6px;display:flex;gap:6px;padding:4px;border-radius:8px;background:rgba(0,0,0,0.45);backdrop-filter:blur(2px);opacity:0;transition:opacity 0.15s;", ":hover &{opacity:1;}@media (hover:none){opacity:1;}"], Tile);
24
28
  export const EmptyState = styled.div.withConfig({
25
29
  displayName: "EmptyState",
26
- componentId: "sc-14317rj-5"
30
+ componentId: "sc-14317rj-6"
27
31
  })(["text-align:center;padding:48px 16px;color:", ";"], Color.LOCKED);
28
32
  export const Dropzone = styled.button.withConfig({
29
33
  displayName: "Dropzone",
30
- componentId: "sc-14317rj-6"
34
+ componentId: "sc-14317rj-7"
31
35
  })(["width:100%;min-height:96px;margin-bottom:16px;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:8px;padding:16px;font:inherit;font-size:13px;color:", ";cursor:pointer;border:2px dashed ", ";border-radius:8px;background:", ";transition:border-color 0.15s,background 0.15s;&:hover{border-color:", ";}&:disabled{cursor:default;opacity:0.7;}"], Color.LOCKED, ({
32
36
  $dragOver
33
37
  }) => $dragOver ? Color.PRIMARY : Color.DIVIDER, ({
@@ -35,46 +39,46 @@ export const Dropzone = styled.button.withConfig({
35
39
  }) => $dragOver ? Color.SOFT_LIGHT : Color.CLEAR, Color.PRIMARY);
36
40
  export const HiddenInput = styled.input.withConfig({
37
41
  displayName: "HiddenInput",
38
- componentId: "sc-14317rj-7"
42
+ componentId: "sc-14317rj-8"
39
43
  })(["display:none;"]);
40
44
  export const UsageLine = styled.div.withConfig({
41
45
  displayName: "UsageLine",
42
- componentId: "sc-14317rj-8"
46
+ componentId: "sc-14317rj-9"
43
47
  })(["margin-left:auto;font-size:", ";color:", ";"], FontSize.SMALL, Color.LOCKED);
44
48
  /* ---------- CreateCropDrawer ---------- */
45
49
  export const PreviewStage = styled.div.withConfig({
46
50
  displayName: "PreviewStage",
47
- componentId: "sc-14317rj-9"
51
+ componentId: "sc-14317rj-10"
48
52
  })(["display:flex;align-items:center;justify-content:center;min-height:200px;max-height:360px;padding:12px;border:1px solid ", ";border-radius:6px;overflow:hidden;background:", ";"], Color.DIVIDER, Color.SOFT_LIGHT);
49
53
  export const PreviewFrame = styled.div.withConfig({
50
54
  displayName: "PreviewFrame",
51
- componentId: "sc-14317rj-10"
55
+ componentId: "sc-14317rj-11"
52
56
  })(["aspect-ratio:", ";max-width:100%;max-height:336px;overflow:hidden;border-radius:4px;"], ({
53
57
  $ratio
54
58
  }) => $ratio);
55
59
  export const PreviewImage = styled.img.withConfig({
56
60
  displayName: "PreviewImage",
57
- componentId: "sc-14317rj-11"
61
+ componentId: "sc-14317rj-12"
58
62
  })(["width:100%;height:100%;object-fit:cover;display:block;"]);
59
63
  export const FieldLabel = styled.div.withConfig({
60
64
  displayName: "FieldLabel",
61
- componentId: "sc-14317rj-12"
65
+ componentId: "sc-14317rj-13"
62
66
  })(["font-size:11px;font-weight:600;text-transform:uppercase;letter-spacing:0.4px;color:", ";margin-bottom:6px;"], Color.LOCKED);
63
67
  export const SizeInput = styled(NativeInput).withConfig({
64
68
  displayName: "SizeInput",
65
- componentId: "sc-14317rj-13"
69
+ componentId: "sc-14317rj-14"
66
70
  })(["&[readonly]{background:", ";color:", ";}"], Color.SOFT_LIGHT, Color.LOCKED);
67
71
  export const Chips = styled.div.withConfig({
68
72
  displayName: "Chips",
69
- componentId: "sc-14317rj-14"
73
+ componentId: "sc-14317rj-15"
70
74
  })(["display:flex;flex-wrap:wrap;gap:7px;"]);
71
75
  export const Segment = styled.div.withConfig({
72
76
  displayName: "Segment",
73
- componentId: "sc-14317rj-15"
77
+ componentId: "sc-14317rj-16"
74
78
  })(["display:inline-flex;border:1px solid ", ";border-radius:6px;overflow:hidden;"], Color.DIVIDER);
75
79
  export const SegmentItem = styled.button.withConfig({
76
80
  displayName: "SegmentItem",
77
- componentId: "sc-14317rj-16"
81
+ componentId: "sc-14317rj-17"
78
82
  })(["font:inherit;font-size:13px;cursor:pointer;padding:7px 14px;border:none;background:", ";color:", ";& + &{border-left:1px solid ", ";}"], ({
79
83
  $active
80
84
  }) => $active ? Color.PRIMARY : Color.CLEAR, ({
@@ -82,17 +86,17 @@ export const SegmentItem = styled.button.withConfig({
82
86
  }) => $active ? Color.CLEAR : Color.MAIN, Color.DIVIDER);
83
87
  export const CropStrip = styled.div.withConfig({
84
88
  displayName: "CropStrip",
85
- componentId: "sc-14317rj-17"
89
+ componentId: "sc-14317rj-18"
86
90
  })(["display:flex;gap:10px;flex-wrap:wrap;align-items:flex-start;"]);
87
91
  export const CropThumb = styled.div.withConfig({
88
92
  displayName: "CropThumb",
89
- componentId: "sc-14317rj-18"
93
+ componentId: "sc-14317rj-19"
90
94
  })(["position:relative;width:88px;border:1px solid ", ";border-radius:6px;overflow:hidden;background:", ";"], Color.DIVIDER, Color.SOFT_LIGHT);
91
95
  export const CropThumbDelete = styled.div.withConfig({
92
96
  displayName: "CropThumbDelete",
93
- componentId: "sc-14317rj-19"
97
+ componentId: "sc-14317rj-20"
94
98
  })(["position:absolute;top:4px;right:4px;opacity:0;transition:opacity 0.15s;", ":hover &{opacity:1;}@media (hover:none){opacity:1;}"], CropThumb);
95
99
  export const CropThumbCaption = styled.div.withConfig({
96
100
  displayName: "CropThumbCaption",
97
- componentId: "sc-14317rj-20"
101
+ componentId: "sc-14317rj-21"
98
102
  })(["font-size:10px;text-align:center;padding:3px 2px;color:", ";"], Color.LOCKED);
@@ -1,5 +1,20 @@
1
1
  import type { MediaFile } from '@pushwoosh/rpc-v2-http-api-data/media_files';
2
- export type MediaStorageProps = {
2
+ /** Per-channel options both gallery entry points accept; `MediaStoragePaginated` forwards them as-is. */
3
+ type MediaFileChannelOptions = {
4
+ /**
5
+ * Shows only files whose stored path ends with one of these extensions (`"jpg"` or `".jpg"`,
6
+ * case-insensitive); omit to show the whole library. The list goes into the `List` request,
7
+ * so `total` and the page count match what is rendered — filtering the response instead
8
+ * would let a page collapse to zero tiles while later pages still hold matches.
9
+ */
10
+ extensions?: string[];
11
+ /**
12
+ * Warning to show on a file's tile, or undefined for none. The text belongs to the caller:
13
+ * the kit has no opinion on why a file is risky for a particular channel.
14
+ */
15
+ fileWarning?: (file: MediaFile) => string | undefined;
16
+ };
17
+ export type MediaStorageProps = MediaFileChannelOptions & {
3
18
  /** Application code whose media files are listed. */
4
19
  application: string;
5
20
  /** Current page (zero-based). Controlled by the parent. */
@@ -12,7 +27,7 @@ export type MediaStorageProps = {
12
27
  * asset, `uuid` its gallery id. */
13
28
  onSelect: (file: MediaFile) => void;
14
29
  };
15
- export type MediaStoragePaginatedProps = {
30
+ export type MediaStoragePaginatedProps = MediaFileChannelOptions & {
16
31
  /** Application code whose media files are listed. */
17
32
  application: string;
18
33
  /** Initial page to load (zero-based). Defaults to 0. */
@@ -23,3 +38,4 @@ export type MediaStoragePaginatedProps = {
23
38
  * asset, `uuid` its gallery id. */
24
39
  onSelect: (file: MediaFile) => void;
25
40
  };
41
+ export {};
package/index.d.ts CHANGED
@@ -8,6 +8,7 @@ export * from './ExportCSV';
8
8
  export * from './PlatformsConfigurationDrawer';
9
9
  export * from './LanguagesSelector';
10
10
  export * from './MediaStorage';
11
+ export * from './mmsAttachments';
11
12
  export * from './ResendModal';
12
13
  export * from './RichMessageEditorSmart';
13
14
  export * from './Table';
package/index.js CHANGED
@@ -8,6 +8,7 @@ export * from './ExportCSV';
8
8
  export * from './PlatformsConfigurationDrawer';
9
9
  export * from './LanguagesSelector';
10
10
  export * from './MediaStorage';
11
+ export * from './mmsAttachments';
11
12
  export * from './ResendModal';
12
13
  export * from './RichMessageEditorSmart';
13
14
  export * from './Table';
@@ -0,0 +1 @@
1
+ export { MMS_ALLOWED_EXTENSIONS, MMS_MAX_ATTACHMENTS, MMS_SAFE_FILE_SIZE_BYTES, MMS_SUBJECT_MAX_ASCII_LENGTH, MMS_SUBJECT_MAX_UNICODE_LENGTH, getMmsSubjectLength, getMmsSubjectMaxLength, isMmsAttachmentOversized, isMmsAttachmentUrl, isMmsMessageAtValid, isMmsSubjectTooLong, } from './rules';
@@ -0,0 +1 @@
1
+ export { MMS_ALLOWED_EXTENSIONS, MMS_MAX_ATTACHMENTS, MMS_SAFE_FILE_SIZE_BYTES, MMS_SUBJECT_MAX_ASCII_LENGTH, MMS_SUBJECT_MAX_UNICODE_LENGTH, getMmsSubjectLength, getMmsSubjectMaxLength, isMmsAttachmentOversized, isMmsAttachmentUrl, isMmsMessageAtValid, isMmsSubjectTooLong } from './rules';
@@ -0,0 +1,35 @@
1
+ import type { MediaFile } from '@pushwoosh/rpc-v2-http-api-data/media_files';
2
+ /**
3
+ * Limits mirrored from messaging-api validation (`internal/api/validation/content-sms.go`).
4
+ * Breaking any of them is a hard 2073/2074 error on send, so the form must not let it through.
5
+ */
6
+ export declare const MMS_MAX_ATTACHMENTS = 3;
7
+ export declare const MMS_SUBJECT_MAX_ASCII_LENGTH = 40;
8
+ export declare const MMS_SUBJECT_MAX_UNICODE_LENGTH = 13;
9
+ export declare const MMS_ALLOWED_EXTENSIONS: string[];
10
+ /**
11
+ * Attachments above this stay compatible with older networks and handsets. It is a
12
+ * recommendation, not a provider limit — warn, never block.
13
+ */
14
+ export declare const MMS_SAFE_FILE_SIZE_BYTES: number;
15
+ /**
16
+ * The provider downloads the attachment itself, so it has to be a public https url whose
17
+ * *path* ends with a supported image extension. Checking the path (not the whole url) matches
18
+ * the backend and keeps query strings — signatures, cache busters — from passing as extensions.
19
+ *
20
+ * The same rule also hides WebP and the legacy media-library files stored without an extension:
21
+ * both are fetchable, neither is accepted downstream.
22
+ */
23
+ export declare const isMmsAttachmentUrl: (url: string) => boolean;
24
+ /** Whether the file is large enough to warrant the "may not arrive" warning. */
25
+ export declare const isMmsAttachmentOversized: (file: MediaFile) => boolean;
26
+ /**
27
+ * Subject length budget: the provider counts non-ASCII subjects against a much shorter
28
+ * limit, so a single non-ASCII character drops the whole subject to 13.
29
+ */
30
+ export declare const getMmsSubjectMaxLength: (subject: string) => number;
31
+ /** Subject length in characters, counting astral symbols (emoji) as one. */
32
+ export declare const getMmsSubjectLength: (subject: string) => number;
33
+ export declare const isMmsSubjectTooLong: (subject: string) => boolean;
34
+ /** Whether message_at points at one of the attachments, as the backend requires. */
35
+ export declare const isMmsMessageAtValid: (messageAt: number, attachmentsCount: number) => boolean;
@@ -0,0 +1,46 @@
1
+ /**
2
+ * Limits mirrored from messaging-api validation (`internal/api/validation/content-sms.go`).
3
+ * Breaking any of them is a hard 2073/2074 error on send, so the form must not let it through.
4
+ */
5
+ export const MMS_MAX_ATTACHMENTS = 3;
6
+ export const MMS_SUBJECT_MAX_ASCII_LENGTH = 40;
7
+ export const MMS_SUBJECT_MAX_UNICODE_LENGTH = 13;
8
+ export const MMS_ALLOWED_EXTENSIONS = ['.jpg', '.jpeg', '.png', '.gif'];
9
+ /**
10
+ * Attachments above this stay compatible with older networks and handsets. It is a
11
+ * recommendation, not a provider limit — warn, never block.
12
+ */
13
+ export const MMS_SAFE_FILE_SIZE_BYTES = 300 * 1024;
14
+ /**
15
+ * The provider downloads the attachment itself, so it has to be a public https url whose
16
+ * *path* ends with a supported image extension. Checking the path (not the whole url) matches
17
+ * the backend and keeps query strings — signatures, cache busters — from passing as extensions.
18
+ *
19
+ * The same rule also hides WebP and the legacy media-library files stored without an extension:
20
+ * both are fetchable, neither is accepted downstream.
21
+ */
22
+ export const isMmsAttachmentUrl = url => {
23
+ let parsed;
24
+ try {
25
+ parsed = new URL(url);
26
+ } catch {
27
+ return false;
28
+ }
29
+ if (parsed.protocol !== 'https:' || !parsed.host) return false;
30
+ const path = parsed.pathname.toLowerCase();
31
+ return MMS_ALLOWED_EXTENSIONS.some(extension => path.endsWith(extension));
32
+ };
33
+ /** Whether the file is large enough to warrant the "may not arrive" warning. */
34
+ export const isMmsAttachmentOversized = file => file.fileSize > MMS_SAFE_FILE_SIZE_BYTES;
35
+ /**
36
+ * Subject length budget: the provider counts non-ASCII subjects against a much shorter
37
+ * limit, so a single non-ASCII character drops the whole subject to 13.
38
+ */
39
+ export const getMmsSubjectMaxLength = subject =>
40
+ // eslint-disable-next-line no-control-regex
41
+ /^[\x00-\x7F]*$/.test(subject) ? MMS_SUBJECT_MAX_ASCII_LENGTH : MMS_SUBJECT_MAX_UNICODE_LENGTH;
42
+ /** Subject length in characters, counting astral symbols (emoji) as one. */
43
+ export const getMmsSubjectLength = subject => [...subject].length;
44
+ export const isMmsSubjectTooLong = subject => getMmsSubjectLength(subject) > getMmsSubjectMaxLength(subject);
45
+ /** Whether message_at points at one of the attachments, as the backend requires. */
46
+ export const isMmsMessageAtValid = (messageAt, attachmentsCount) => Number.isInteger(messageAt) && messageAt >= 0 && messageAt < attachmentsCount;
package/package.json CHANGED
@@ -24,12 +24,12 @@
24
24
  },
25
25
  "dependencies": {
26
26
  "@floating-ui/react-dom": "^2.1.2",
27
- "@pushwoosh/dumb-components": "^1.1.175",
27
+ "@pushwoosh/dumb-components": "^1.1.182",
28
28
  "@pushwoosh/kit-constants": "^1.8.0",
29
29
  "@pushwoosh/kit-helpers": "^4.12.0",
30
30
  "@pushwoosh/kit-icons": "^2.1.22",
31
31
  "@pushwoosh/kit-typography": "^1.7.6",
32
- "@pushwoosh/rpc-v2-http-api-data": "^0.2.70",
32
+ "@pushwoosh/rpc-v2-http-api-data": "^0.2.105",
33
33
  "@pushwoosh/spa-context": "^1.7.1",
34
34
  "idb-keyval": "^6.2.2",
35
35
  "react-hook-form": "^7.68.0",
@@ -52,5 +52,5 @@
52
52
  "react": "^16.14.0 || ^18.0.0",
53
53
  "react-dom": "^16.14.0 || ^18.0.0"
54
54
  },
55
- "version": "1.3.4"
55
+ "version": "1.3.6"
56
56
  }