@greatapps/common 1.1.447 → 1.1.449

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.
Files changed (49) hide show
  1. package/dist/components/modals/cards/AddCardModal.mjs +5 -2
  2. package/dist/components/modals/cards/AddCardModal.mjs.map +1 -1
  3. package/dist/components/widgets/ImageUpload/ImageCropModal.mjs +76 -0
  4. package/dist/components/widgets/ImageUpload/ImageCropModal.mjs.map +1 -0
  5. package/dist/components/widgets/ImageUpload/ImageUpload.mjs +121 -0
  6. package/dist/components/widgets/ImageUpload/ImageUpload.mjs.map +1 -0
  7. package/dist/components/widgets/ImageUpload/index.mjs +7 -0
  8. package/dist/components/widgets/ImageUpload/index.mjs.map +1 -0
  9. package/dist/index.mjs +29 -1
  10. package/dist/index.mjs.map +1 -1
  11. package/dist/infra/utils/date.mjs +11 -0
  12. package/dist/infra/utils/date.mjs.map +1 -1
  13. package/dist/modules/images/actions/process-image.action.mjs +31 -0
  14. package/dist/modules/images/actions/process-image.action.mjs.map +1 -0
  15. package/dist/modules/images/constants/image.constants.mjs +14 -0
  16. package/dist/modules/images/constants/image.constants.mjs.map +1 -0
  17. package/dist/modules/images/hooks/use-image-upload.hook.mjs +96 -0
  18. package/dist/modules/images/hooks/use-image-upload.hook.mjs.map +1 -0
  19. package/dist/modules/images/services/image-processing.service.mjs +70 -0
  20. package/dist/modules/images/services/image-processing.service.mjs.map +1 -0
  21. package/dist/modules/images/types/image.type.mjs +1 -0
  22. package/dist/modules/images/types/image.type.mjs.map +1 -0
  23. package/dist/modules/images/utils/compress-image.mjs +18 -0
  24. package/dist/modules/images/utils/compress-image.mjs.map +1 -0
  25. package/dist/modules/images/utils/crop-image.mjs +29 -0
  26. package/dist/modules/images/utils/crop-image.mjs.map +1 -0
  27. package/dist/modules/images/utils/validate-image.mjs +50 -0
  28. package/dist/modules/images/utils/validate-image.mjs.map +1 -0
  29. package/dist/modules/plans/services/plans.service.mjs +15 -11
  30. package/dist/modules/plans/services/plans.service.mjs.map +1 -1
  31. package/dist/server.mjs +4 -0
  32. package/dist/server.mjs.map +1 -1
  33. package/package.json +4 -1
  34. package/src/components/modals/cards/AddCardModal.tsx +2 -1
  35. package/src/components/widgets/ImageUpload/ImageCropModal.tsx +97 -0
  36. package/src/components/widgets/ImageUpload/ImageUpload.tsx +125 -0
  37. package/src/components/widgets/ImageUpload/index.ts +2 -0
  38. package/src/index.ts +24 -1
  39. package/src/infra/utils/date.ts +11 -0
  40. package/src/modules/images/actions/process-image.action.ts +38 -0
  41. package/src/modules/images/constants/image.constants.ts +9 -0
  42. package/src/modules/images/hooks/use-image-upload.hook.ts +111 -0
  43. package/src/modules/images/services/image-processing.service.ts +76 -0
  44. package/src/modules/images/types/image.type.ts +54 -0
  45. package/src/modules/images/utils/compress-image.ts +21 -0
  46. package/src/modules/images/utils/crop-image.ts +41 -0
  47. package/src/modules/images/utils/validate-image.ts +59 -0
  48. package/src/modules/plans/services/plans.service.ts +48 -38
  49. package/src/server.ts +10 -0
@@ -0,0 +1,96 @@
1
+ "use client";
2
+ import { useState, useCallback } from "react";
3
+ import { useMutation } from "@tanstack/react-query";
4
+ import { compressImage } from "../utils/compress-image";
5
+ import { cropImageToCanvas } from "../utils/crop-image";
6
+ import { validateImage } from "../utils/validate-image";
7
+ import { processImageAction } from "../actions/process-image.action";
8
+ function useImageUpload(options) {
9
+ const { config, onSuccess, onError } = options;
10
+ const [originalImage, setOriginalImage] = useState(null);
11
+ const [croppedPreview, setCroppedPreview] = useState(null);
12
+ const [showCropModal, setShowCropModal] = useState(false);
13
+ const processMutation = useMutation({
14
+ mutationFn: processImageAction,
15
+ onSuccess: (result) => {
16
+ if (result.success && result.base64Image) {
17
+ setCroppedPreview(result.base64Image);
18
+ onSuccess?.(result.base64Image);
19
+ } else {
20
+ onError?.(result.error || "Erro desconhecido");
21
+ }
22
+ },
23
+ onError: (error) => {
24
+ onError?.(error instanceof Error ? error.message : "Erro ao processar");
25
+ }
26
+ });
27
+ const handleFileSelect = useCallback(
28
+ async (file) => {
29
+ const validation = validateImage(file);
30
+ if (!validation.valid) {
31
+ onError?.(validation.error || "Arquivo inv\xE1lido");
32
+ return;
33
+ }
34
+ try {
35
+ const compressed = await compressImage(file);
36
+ const reader = new FileReader();
37
+ reader.onload = (e) => {
38
+ setOriginalImage(e.target?.result);
39
+ setShowCropModal(true);
40
+ };
41
+ reader.readAsDataURL(compressed);
42
+ } catch {
43
+ onError?.("Erro ao processar imagem");
44
+ }
45
+ },
46
+ [onError]
47
+ );
48
+ const handleCropConfirm = useCallback(
49
+ async (imageElement, cropArea) => {
50
+ try {
51
+ const croppedBase64 = await cropImageToCanvas(
52
+ imageElement,
53
+ cropArea,
54
+ config.width,
55
+ config.height
56
+ );
57
+ setCroppedPreview(croppedBase64);
58
+ setShowCropModal(false);
59
+ processMutation.mutate({
60
+ base64Image: croppedBase64,
61
+ targetWidth: config.width,
62
+ targetHeight: config.height,
63
+ cropArea: {
64
+ x: cropArea.x,
65
+ y: cropArea.y,
66
+ width: cropArea.width,
67
+ height: cropArea.height,
68
+ unit: cropArea.unit
69
+ }
70
+ });
71
+ } catch {
72
+ onError?.("Erro ao aplicar crop");
73
+ }
74
+ },
75
+ [config, processMutation, onError]
76
+ );
77
+ const clear = useCallback(() => {
78
+ setOriginalImage(null);
79
+ setCroppedPreview(null);
80
+ }, []);
81
+ return {
82
+ originalImage,
83
+ croppedPreview,
84
+ showCropModal,
85
+ config,
86
+ isProcessing: processMutation.isPending,
87
+ handleFileSelect,
88
+ handleCropConfirm,
89
+ setShowCropModal,
90
+ clear
91
+ };
92
+ }
93
+ export {
94
+ useImageUpload
95
+ };
96
+ //# sourceMappingURL=use-image-upload.hook.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../../../src/modules/images/hooks/use-image-upload.hook.ts"],"sourcesContent":["'use client'\n\nimport { useState, useCallback } from 'react'\nimport { useMutation } from '@tanstack/react-query'\nimport type { Crop } from 'react-image-crop'\nimport { compressImage } from '../utils/compress-image'\nimport { cropImageToCanvas } from '../utils/crop-image'\nimport { validateImage } from '../utils/validate-image'\nimport { processImageAction } from '../actions/process-image.action'\nimport type { ImageConfig } from '../types/image.type'\n\nexport interface UseImageUploadOptions {\n config: ImageConfig\n onSuccess?: (base64Image: string) => void\n onError?: (error: string) => void\n}\n\nexport function useImageUpload(options: UseImageUploadOptions) {\n const { config, onSuccess, onError } = options\n\n const [originalImage, setOriginalImage] = useState<string | null>(null)\n const [croppedPreview, setCroppedPreview] = useState<string | null>(null)\n const [showCropModal, setShowCropModal] = useState(false)\n\n const processMutation = useMutation({\n mutationFn: processImageAction,\n onSuccess: (result) => {\n if (result.success && result.base64Image) {\n setCroppedPreview(result.base64Image)\n onSuccess?.(result.base64Image)\n } else {\n onError?.(result.error || 'Erro desconhecido')\n }\n },\n onError: (error) => {\n onError?.(error instanceof Error ? error.message : 'Erro ao processar')\n },\n })\n\n const handleFileSelect = useCallback(\n async (file: File) => {\n const validation = validateImage(file)\n if (!validation.valid) {\n onError?.(validation.error || 'Arquivo inválido')\n return\n }\n\n try {\n const compressed = await compressImage(file)\n const reader = new FileReader()\n reader.onload = (e) => {\n setOriginalImage(e.target?.result as string)\n setShowCropModal(true)\n }\n reader.readAsDataURL(compressed)\n } catch {\n onError?.('Erro ao processar imagem')\n }\n },\n [onError]\n )\n\n const handleCropConfirm = useCallback(\n async (imageElement: HTMLImageElement, cropArea: Crop) => {\n try {\n const croppedBase64 = await cropImageToCanvas(\n imageElement,\n cropArea,\n config.width,\n config.height\n )\n\n setCroppedPreview(croppedBase64)\n setShowCropModal(false)\n\n processMutation.mutate({\n base64Image: croppedBase64,\n targetWidth: config.width,\n targetHeight: config.height,\n cropArea: {\n x: cropArea.x,\n y: cropArea.y,\n width: cropArea.width,\n height: cropArea.height,\n unit: cropArea.unit,\n },\n })\n } catch {\n onError?.('Erro ao aplicar crop')\n }\n },\n [config, processMutation, onError]\n )\n\n const clear = useCallback(() => {\n setOriginalImage(null)\n setCroppedPreview(null)\n }, [])\n\n return {\n originalImage,\n croppedPreview,\n showCropModal,\n config,\n isProcessing: processMutation.isPending,\n handleFileSelect,\n handleCropConfirm,\n setShowCropModal,\n clear,\n }\n}\n"],"mappings":";AAEA,SAAS,UAAU,mBAAmB;AACtC,SAAS,mBAAmB;AAE5B,SAAS,qBAAqB;AAC9B,SAAS,yBAAyB;AAClC,SAAS,qBAAqB;AAC9B,SAAS,0BAA0B;AAS5B,SAAS,eAAe,SAAgC;AAC7D,QAAM,EAAE,QAAQ,WAAW,QAAQ,IAAI;AAEvC,QAAM,CAAC,eAAe,gBAAgB,IAAI,SAAwB,IAAI;AACtE,QAAM,CAAC,gBAAgB,iBAAiB,IAAI,SAAwB,IAAI;AACxE,QAAM,CAAC,eAAe,gBAAgB,IAAI,SAAS,KAAK;AAExD,QAAM,kBAAkB,YAAY;AAAA,IAClC,YAAY;AAAA,IACZ,WAAW,CAAC,WAAW;AACrB,UAAI,OAAO,WAAW,OAAO,aAAa;AACxC,0BAAkB,OAAO,WAAW;AACpC,oBAAY,OAAO,WAAW;AAAA,MAChC,OAAO;AACL,kBAAU,OAAO,SAAS,mBAAmB;AAAA,MAC/C;AAAA,IACF;AAAA,IACA,SAAS,CAAC,UAAU;AAClB,gBAAU,iBAAiB,QAAQ,MAAM,UAAU,mBAAmB;AAAA,IACxE;AAAA,EACF,CAAC;AAED,QAAM,mBAAmB;AAAA,IACvB,OAAO,SAAe;AACpB,YAAM,aAAa,cAAc,IAAI;AACrC,UAAI,CAAC,WAAW,OAAO;AACrB,kBAAU,WAAW,SAAS,qBAAkB;AAChD;AAAA,MACF;AAEA,UAAI;AACF,cAAM,aAAa,MAAM,cAAc,IAAI;AAC3C,cAAM,SAAS,IAAI,WAAW;AAC9B,eAAO,SAAS,CAAC,MAAM;AACrB,2BAAiB,EAAE,QAAQ,MAAgB;AAC3C,2BAAiB,IAAI;AAAA,QACvB;AACA,eAAO,cAAc,UAAU;AAAA,MACjC,QAAQ;AACN,kBAAU,0BAA0B;AAAA,MACtC;AAAA,IACF;AAAA,IACA,CAAC,OAAO;AAAA,EACV;AAEA,QAAM,oBAAoB;AAAA,IACxB,OAAO,cAAgC,aAAmB;AACxD,UAAI;AACF,cAAM,gBAAgB,MAAM;AAAA,UAC1B;AAAA,UACA;AAAA,UACA,OAAO;AAAA,UACP,OAAO;AAAA,QACT;AAEA,0BAAkB,aAAa;AAC/B,yBAAiB,KAAK;AAEtB,wBAAgB,OAAO;AAAA,UACrB,aAAa;AAAA,UACb,aAAa,OAAO;AAAA,UACpB,cAAc,OAAO;AAAA,UACrB,UAAU;AAAA,YACR,GAAG,SAAS;AAAA,YACZ,GAAG,SAAS;AAAA,YACZ,OAAO,SAAS;AAAA,YAChB,QAAQ,SAAS;AAAA,YACjB,MAAM,SAAS;AAAA,UACjB;AAAA,QACF,CAAC;AAAA,MACH,QAAQ;AACN,kBAAU,sBAAsB;AAAA,MAClC;AAAA,IACF;AAAA,IACA,CAAC,QAAQ,iBAAiB,OAAO;AAAA,EACnC;AAEA,QAAM,QAAQ,YAAY,MAAM;AAC9B,qBAAiB,IAAI;AACrB,sBAAkB,IAAI;AAAA,EACxB,GAAG,CAAC,CAAC;AAEL,SAAO;AAAA,IACL;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA,IACA,cAAc,gBAAgB;AAAA,IAC9B;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA,EACF;AACF;","names":[]}
@@ -0,0 +1,70 @@
1
+ import {
2
+ PhotonImage,
3
+ resize,
4
+ crop,
5
+ SamplingFilter
6
+ } from "@cf-wasm/photon/edge-light";
7
+ function processImage(input) {
8
+ const {
9
+ imageBytes,
10
+ targetWidth,
11
+ targetHeight,
12
+ outputFormat = "jpeg",
13
+ quality = 85
14
+ } = input;
15
+ const image = PhotonImage.new_from_byteslice(imageBytes);
16
+ try {
17
+ const sourceWidth = image.get_width();
18
+ const sourceHeight = image.get_height();
19
+ const targetAspect = targetWidth / targetHeight;
20
+ const sourceAspect = sourceWidth / sourceHeight;
21
+ let cropX1 = 0;
22
+ let cropY1 = 0;
23
+ let cropX2 = sourceWidth;
24
+ let cropY2 = sourceHeight;
25
+ if (sourceAspect > targetAspect) {
26
+ const cropWidth = Math.round(sourceHeight * targetAspect);
27
+ cropX1 = Math.round((sourceWidth - cropWidth) / 2);
28
+ cropX2 = cropX1 + cropWidth;
29
+ } else if (sourceAspect < targetAspect) {
30
+ const cropHeight = Math.round(sourceWidth / targetAspect);
31
+ cropY1 = Math.round((sourceHeight - cropHeight) / 2);
32
+ cropY2 = cropY1 + cropHeight;
33
+ }
34
+ const cropped = crop(image, cropX1, cropY1, cropX2, cropY2);
35
+ const resized = resize(
36
+ cropped,
37
+ targetWidth,
38
+ targetHeight,
39
+ SamplingFilter.Triangle
40
+ );
41
+ let outputBytes;
42
+ switch (outputFormat) {
43
+ case "jpeg":
44
+ outputBytes = resized.get_bytes_jpeg(quality);
45
+ break;
46
+ case "png":
47
+ outputBytes = resized.get_bytes();
48
+ break;
49
+ case "webp":
50
+ outputBytes = resized.get_bytes_webp();
51
+ break;
52
+ default:
53
+ outputBytes = resized.get_bytes_jpeg(quality);
54
+ }
55
+ cropped.free();
56
+ resized.free();
57
+ return {
58
+ bytes: outputBytes,
59
+ width: targetWidth,
60
+ height: targetHeight,
61
+ format: `image/${outputFormat}`
62
+ };
63
+ } finally {
64
+ image.free();
65
+ }
66
+ }
67
+ export {
68
+ processImage
69
+ };
70
+ //# sourceMappingURL=image-processing.service.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../../../src/modules/images/services/image-processing.service.ts"],"sourcesContent":["import {\n PhotonImage,\n resize,\n crop,\n SamplingFilter,\n} from '@cf-wasm/photon/edge-light'\nimport type { ProcessImageInput, ProcessImageOutput } from '../types/image.type'\n\nexport function processImage(input: ProcessImageInput): ProcessImageOutput {\n const {\n imageBytes,\n targetWidth,\n targetHeight,\n outputFormat = 'jpeg',\n quality = 85,\n } = input\n\n const image = PhotonImage.new_from_byteslice(imageBytes)\n\n try {\n const sourceWidth = image.get_width()\n const sourceHeight = image.get_height()\n const targetAspect = targetWidth / targetHeight\n const sourceAspect = sourceWidth / sourceHeight\n\n let cropX1 = 0\n let cropY1 = 0\n let cropX2 = sourceWidth\n let cropY2 = sourceHeight\n\n if (sourceAspect > targetAspect) {\n const cropWidth = Math.round(sourceHeight * targetAspect)\n cropX1 = Math.round((sourceWidth - cropWidth) / 2)\n cropX2 = cropX1 + cropWidth\n } else if (sourceAspect < targetAspect) {\n const cropHeight = Math.round(sourceWidth / targetAspect)\n cropY1 = Math.round((sourceHeight - cropHeight) / 2)\n cropY2 = cropY1 + cropHeight\n }\n\n const cropped = crop(image, cropX1, cropY1, cropX2, cropY2)\n const resized = resize(\n cropped,\n targetWidth,\n targetHeight,\n SamplingFilter.Triangle\n )\n\n let outputBytes: Uint8Array\n switch (outputFormat) {\n case 'jpeg':\n outputBytes = resized.get_bytes_jpeg(quality)\n break\n case 'png':\n outputBytes = resized.get_bytes()\n break\n case 'webp':\n outputBytes = resized.get_bytes_webp()\n break\n default:\n outputBytes = resized.get_bytes_jpeg(quality)\n }\n\n cropped.free()\n resized.free()\n\n return {\n bytes: outputBytes,\n width: targetWidth,\n height: targetHeight,\n format: `image/${outputFormat}`,\n }\n } finally {\n image.free()\n }\n}\n"],"mappings":"AAAA;AAAA,EACE;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,OACK;AAGA,SAAS,aAAa,OAA8C;AACzE,QAAM;AAAA,IACJ;AAAA,IACA;AAAA,IACA;AAAA,IACA,eAAe;AAAA,IACf,UAAU;AAAA,EACZ,IAAI;AAEJ,QAAM,QAAQ,YAAY,mBAAmB,UAAU;AAEvD,MAAI;AACF,UAAM,cAAc,MAAM,UAAU;AACpC,UAAM,eAAe,MAAM,WAAW;AACtC,UAAM,eAAe,cAAc;AACnC,UAAM,eAAe,cAAc;AAEnC,QAAI,SAAS;AACb,QAAI,SAAS;AACb,QAAI,SAAS;AACb,QAAI,SAAS;AAEb,QAAI,eAAe,cAAc;AAC/B,YAAM,YAAY,KAAK,MAAM,eAAe,YAAY;AACxD,eAAS,KAAK,OAAO,cAAc,aAAa,CAAC;AACjD,eAAS,SAAS;AAAA,IACpB,WAAW,eAAe,cAAc;AACtC,YAAM,aAAa,KAAK,MAAM,cAAc,YAAY;AACxD,eAAS,KAAK,OAAO,eAAe,cAAc,CAAC;AACnD,eAAS,SAAS;AAAA,IACpB;AAEA,UAAM,UAAU,KAAK,OAAO,QAAQ,QAAQ,QAAQ,MAAM;AAC1D,UAAM,UAAU;AAAA,MACd;AAAA,MACA;AAAA,MACA;AAAA,MACA,eAAe;AAAA,IACjB;AAEA,QAAI;AACJ,YAAQ,cAAc;AAAA,MACpB,KAAK;AACH,sBAAc,QAAQ,eAAe,OAAO;AAC5C;AAAA,MACF,KAAK;AACH,sBAAc,QAAQ,UAAU;AAChC;AAAA,MACF,KAAK;AACH,sBAAc,QAAQ,eAAe;AACrC;AAAA,MACF;AACE,sBAAc,QAAQ,eAAe,OAAO;AAAA,IAChD;AAEA,YAAQ,KAAK;AACb,YAAQ,KAAK;AAEb,WAAO;AAAA,MACL,OAAO;AAAA,MACP,OAAO;AAAA,MACP,QAAQ;AAAA,MACR,QAAQ,SAAS,YAAY;AAAA,IAC/B;AAAA,EACF,UAAE;AACA,UAAM,KAAK;AAAA,EACb;AACF;","names":[]}
@@ -0,0 +1 @@
1
+ //# sourceMappingURL=image.type.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":[],"sourcesContent":[],"mappings":"","names":[]}
@@ -0,0 +1,18 @@
1
+ import imageCompression from "browser-image-compression";
2
+ import { TARGET_FILE_SIZE_MB } from "../constants/image.constants";
3
+ async function compressImage(file, options = {}) {
4
+ const { maxSizeMB = TARGET_FILE_SIZE_MB, maxWidthOrHeight = 1920 } = options;
5
+ if (file.size <= maxSizeMB * 1024 * 1024) {
6
+ return file;
7
+ }
8
+ return imageCompression(file, {
9
+ maxSizeMB,
10
+ maxWidthOrHeight,
11
+ useWebWorker: true,
12
+ fileType: file.type
13
+ });
14
+ }
15
+ export {
16
+ compressImage
17
+ };
18
+ //# sourceMappingURL=compress-image.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../../../src/modules/images/utils/compress-image.ts"],"sourcesContent":["import imageCompression from 'browser-image-compression'\nimport type { CompressImageOptions } from '../types/image.type'\nimport { TARGET_FILE_SIZE_MB } from '../constants/image.constants'\n\nexport async function compressImage(\n file: File,\n options: CompressImageOptions = {}\n): Promise<File> {\n const { maxSizeMB = TARGET_FILE_SIZE_MB, maxWidthOrHeight = 1920 } = options\n\n if (file.size <= maxSizeMB * 1024 * 1024) {\n return file\n }\n\n return imageCompression(file, {\n maxSizeMB,\n maxWidthOrHeight,\n useWebWorker: true,\n fileType: file.type as 'image/jpeg' | 'image/png' | 'image/webp',\n })\n}\n"],"mappings":"AAAA,OAAO,sBAAsB;AAE7B,SAAS,2BAA2B;AAEpC,eAAsB,cACpB,MACA,UAAgC,CAAC,GAClB;AACf,QAAM,EAAE,YAAY,qBAAqB,mBAAmB,KAAK,IAAI;AAErE,MAAI,KAAK,QAAQ,YAAY,OAAO,MAAM;AACxC,WAAO;AAAA,EACT;AAEA,SAAO,iBAAiB,MAAM;AAAA,IAC5B;AAAA,IACA;AAAA,IACA,cAAc;AAAA,IACd,UAAU,KAAK;AAAA,EACjB,CAAC;AACH;","names":[]}
@@ -0,0 +1,29 @@
1
+ async function cropImageToCanvas(image, crop, targetWidth, targetHeight) {
2
+ const canvas = document.createElement("canvas");
3
+ const ctx = canvas.getContext("2d");
4
+ if (!ctx) throw new Error("Canvas n\xE3o suportado");
5
+ const pixelCrop = {
6
+ x: crop.unit === "%" ? crop.x / 100 * image.naturalWidth : crop.x,
7
+ y: crop.unit === "%" ? crop.y / 100 * image.naturalHeight : crop.y,
8
+ width: crop.unit === "%" ? crop.width / 100 * image.naturalWidth : crop.width,
9
+ height: crop.unit === "%" ? crop.height / 100 * image.naturalHeight : crop.height
10
+ };
11
+ canvas.width = targetWidth;
12
+ canvas.height = targetHeight;
13
+ ctx.drawImage(
14
+ image,
15
+ pixelCrop.x,
16
+ pixelCrop.y,
17
+ pixelCrop.width,
18
+ pixelCrop.height,
19
+ 0,
20
+ 0,
21
+ targetWidth,
22
+ targetHeight
23
+ );
24
+ return canvas.toDataURL("image/jpeg", 0.9);
25
+ }
26
+ export {
27
+ cropImageToCanvas
28
+ };
29
+ //# sourceMappingURL=crop-image.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../../../src/modules/images/utils/crop-image.ts"],"sourcesContent":["import type { Crop } from 'react-image-crop'\n\nexport async function cropImageToCanvas(\n image: HTMLImageElement,\n crop: Crop,\n targetWidth: number,\n targetHeight: number\n): Promise<string> {\n const canvas = document.createElement('canvas')\n const ctx = canvas.getContext('2d')\n\n if (!ctx) throw new Error('Canvas não suportado')\n\n const pixelCrop = {\n x: crop.unit === '%' ? (crop.x / 100) * image.naturalWidth : crop.x,\n y: crop.unit === '%' ? (crop.y / 100) * image.naturalHeight : crop.y,\n width:\n crop.unit === '%' ? (crop.width / 100) * image.naturalWidth : crop.width,\n height:\n crop.unit === '%'\n ? (crop.height / 100) * image.naturalHeight\n : crop.height,\n }\n\n canvas.width = targetWidth\n canvas.height = targetHeight\n\n ctx.drawImage(\n image,\n pixelCrop.x,\n pixelCrop.y,\n pixelCrop.width,\n pixelCrop.height,\n 0,\n 0,\n targetWidth,\n targetHeight\n )\n\n return canvas.toDataURL('image/jpeg', 0.9)\n}\n"],"mappings":"AAEA,eAAsB,kBACpB,OACA,MACA,aACA,cACiB;AACjB,QAAM,SAAS,SAAS,cAAc,QAAQ;AAC9C,QAAM,MAAM,OAAO,WAAW,IAAI;AAElC,MAAI,CAAC,IAAK,OAAM,IAAI,MAAM,yBAAsB;AAEhD,QAAM,YAAY;AAAA,IAChB,GAAG,KAAK,SAAS,MAAO,KAAK,IAAI,MAAO,MAAM,eAAe,KAAK;AAAA,IAClE,GAAG,KAAK,SAAS,MAAO,KAAK,IAAI,MAAO,MAAM,gBAAgB,KAAK;AAAA,IACnE,OACE,KAAK,SAAS,MAAO,KAAK,QAAQ,MAAO,MAAM,eAAe,KAAK;AAAA,IACrE,QACE,KAAK,SAAS,MACT,KAAK,SAAS,MAAO,MAAM,gBAC5B,KAAK;AAAA,EACb;AAEA,SAAO,QAAQ;AACf,SAAO,SAAS;AAEhB,MAAI;AAAA,IACF;AAAA,IACA,UAAU;AAAA,IACV,UAAU;AAAA,IACV,UAAU;AAAA,IACV,UAAU;AAAA,IACV;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA,EACF;AAEA,SAAO,OAAO,UAAU,cAAc,GAAG;AAC3C;","names":[]}
@@ -0,0 +1,50 @@
1
+ import {
2
+ ACCEPTED_IMAGE_FORMATS,
3
+ MAX_FILE_SIZE_MB
4
+ } from "../constants/image.constants";
5
+ function validateImageFormat(file) {
6
+ if (!ACCEPTED_IMAGE_FORMATS.includes(file.type)) {
7
+ return {
8
+ valid: false,
9
+ error: "Formato n\xE3o suportado. Use PNG, JPEG ou WebP."
10
+ };
11
+ }
12
+ return { valid: true };
13
+ }
14
+ function validateImageSize(file, maxSizeMB = MAX_FILE_SIZE_MB) {
15
+ if (file.size > maxSizeMB * 1024 * 1024) {
16
+ return {
17
+ valid: false,
18
+ error: `Arquivo muito grande. M\xE1ximo ${maxSizeMB}MB.`
19
+ };
20
+ }
21
+ return { valid: true };
22
+ }
23
+ function validateImage(file) {
24
+ const formatValidation = validateImageFormat(file);
25
+ if (!formatValidation.valid) return formatValidation;
26
+ const sizeValidation = validateImageSize(file);
27
+ if (!sizeValidation.valid) return sizeValidation;
28
+ return { valid: true };
29
+ }
30
+ async function getImageDimensions(file) {
31
+ return new Promise((resolve, reject) => {
32
+ const img = new Image();
33
+ img.onload = () => {
34
+ resolve({ width: img.naturalWidth, height: img.naturalHeight });
35
+ URL.revokeObjectURL(img.src);
36
+ };
37
+ img.onerror = () => {
38
+ reject(new Error("Falha ao carregar imagem"));
39
+ URL.revokeObjectURL(img.src);
40
+ };
41
+ img.src = URL.createObjectURL(file);
42
+ });
43
+ }
44
+ export {
45
+ getImageDimensions,
46
+ validateImage,
47
+ validateImageFormat,
48
+ validateImageSize
49
+ };
50
+ //# sourceMappingURL=validate-image.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../../../src/modules/images/utils/validate-image.ts"],"sourcesContent":["import {\n ACCEPTED_IMAGE_FORMATS,\n MAX_FILE_SIZE_MB,\n} from '../constants/image.constants'\n\nexport interface ImageValidationResult {\n valid: boolean\n error?: string\n}\n\nexport function validateImageFormat(file: File): ImageValidationResult {\n if (!ACCEPTED_IMAGE_FORMATS.includes(file.type as (typeof ACCEPTED_IMAGE_FORMATS)[number])) {\n return {\n valid: false,\n error: 'Formato não suportado. Use PNG, JPEG ou WebP.',\n }\n }\n return { valid: true }\n}\n\nexport function validateImageSize(\n file: File,\n maxSizeMB: number = MAX_FILE_SIZE_MB\n): ImageValidationResult {\n if (file.size > maxSizeMB * 1024 * 1024) {\n return {\n valid: false,\n error: `Arquivo muito grande. Máximo ${maxSizeMB}MB.`,\n }\n }\n return { valid: true }\n}\n\nexport function validateImage(file: File): ImageValidationResult {\n const formatValidation = validateImageFormat(file)\n if (!formatValidation.valid) return formatValidation\n\n const sizeValidation = validateImageSize(file)\n if (!sizeValidation.valid) return sizeValidation\n\n return { valid: true }\n}\n\nexport async function getImageDimensions(\n file: File\n): Promise<{ width: number; height: number }> {\n return new Promise((resolve, reject) => {\n const img = new Image()\n img.onload = () => {\n resolve({ width: img.naturalWidth, height: img.naturalHeight })\n URL.revokeObjectURL(img.src)\n }\n img.onerror = () => {\n reject(new Error('Falha ao carregar imagem'))\n URL.revokeObjectURL(img.src)\n }\n img.src = URL.createObjectURL(file)\n })\n}\n"],"mappings":"AAAA;AAAA,EACE;AAAA,EACA;AAAA,OACK;AAOA,SAAS,oBAAoB,MAAmC;AACrE,MAAI,CAAC,uBAAuB,SAAS,KAAK,IAA+C,GAAG;AAC1F,WAAO;AAAA,MACL,OAAO;AAAA,MACP,OAAO;AAAA,IACT;AAAA,EACF;AACA,SAAO,EAAE,OAAO,KAAK;AACvB;AAEO,SAAS,kBACd,MACA,YAAoB,kBACG;AACvB,MAAI,KAAK,OAAO,YAAY,OAAO,MAAM;AACvC,WAAO;AAAA,MACL,OAAO;AAAA,MACP,OAAO,mCAAgC,SAAS;AAAA,IAClD;AAAA,EACF;AACA,SAAO,EAAE,OAAO,KAAK;AACvB;AAEO,SAAS,cAAc,MAAmC;AAC/D,QAAM,mBAAmB,oBAAoB,IAAI;AACjD,MAAI,CAAC,iBAAiB,MAAO,QAAO;AAEpC,QAAM,iBAAiB,kBAAkB,IAAI;AAC7C,MAAI,CAAC,eAAe,MAAO,QAAO;AAElC,SAAO,EAAE,OAAO,KAAK;AACvB;AAEA,eAAsB,mBACpB,MAC4C;AAC5C,SAAO,IAAI,QAAQ,CAAC,SAAS,WAAW;AACtC,UAAM,MAAM,IAAI,MAAM;AACtB,QAAI,SAAS,MAAM;AACjB,cAAQ,EAAE,OAAO,IAAI,cAAc,QAAQ,IAAI,cAAc,CAAC;AAC9D,UAAI,gBAAgB,IAAI,GAAG;AAAA,IAC7B;AACA,QAAI,UAAU,MAAM;AAClB,aAAO,IAAI,MAAM,0BAA0B,CAAC;AAC5C,UAAI,gBAAgB,IAAI,GAAG;AAAA,IAC7B;AACA,QAAI,MAAM,IAAI,gBAAgB,IAAI;AAAA,EACpC,CAAC;AACH;","names":[]}
@@ -1,5 +1,5 @@
1
1
  import "server-only";
2
- import { api } from "@greatapps/common/server";
2
+ import { api, getUserContext } from "@greatapps/common/server";
3
3
  import { ApiError, buildQueryParams, PlanSchema } from "@greatapps/common";
4
4
  import greatCache from "@greatapps/cache";
5
5
  const PLANS_CACHE_TTL = 604800;
@@ -22,7 +22,11 @@ class PlansService {
22
22
  * GET /{id_wl}/plans?active=true&search=client&sort=id:ASC
23
23
  */
24
24
  async listPlans(params) {
25
- const cacheKey = this.buildCacheKey("plans", params);
25
+ const { id_account } = await getUserContext();
26
+ const cacheKey = this.buildCacheKey("plans", {
27
+ ...params,
28
+ id_account
29
+ });
26
30
  const cachedData = await this.cache.select(cacheKey);
27
31
  if (cachedData.status == 1 && "data" in cachedData && cachedData.data) {
28
32
  const data2 = JSON.parse(cachedData.data);
@@ -32,7 +36,7 @@ class PlansService {
32
36
  sort: params?.sort ?? "id:ASC",
33
37
  active: params?.active,
34
38
  search: params?.search,
35
- id_account: params?.id_account
39
+ id_account
36
40
  });
37
41
  const url = `/plans${query ? `?${query}` : ""}`;
38
42
  const response = await api.apps.get(url);
@@ -52,15 +56,19 @@ class PlansService {
52
56
  success: true
53
57
  };
54
58
  }
55
- async findById(idPlan, id_account) {
56
- const cacheKey = this.buildCacheKey(`plan-${idPlan}-${id_account}`);
59
+ async findById(idPlan) {
60
+ const { id_account } = await getUserContext();
61
+ const cacheKey = this.buildCacheKey(`plan-${idPlan}`, {
62
+ id_account: String(id_account)
63
+ });
57
64
  const cachedData = await this.cache.select(cacheKey);
58
65
  if (cachedData.status == 1 && "data" in cachedData && cachedData.data) {
59
66
  const data = JSON.parse(cachedData.data);
60
67
  return { data, success: true };
61
68
  }
69
+ const query = buildQueryParams({ type: "client", id_account });
62
70
  const response = await api.apps.get(
63
- `/plans/${idPlan}?type=client&id_account=${id_account}`
71
+ `/plans/${idPlan}?${query}`
64
72
  );
65
73
  if (response.status === 0) {
66
74
  throw new ApiError(
@@ -70,11 +78,7 @@ class PlansService {
70
78
  );
71
79
  }
72
80
  if (!response.data?.length) {
73
- throw new ApiError(
74
- "Plano n\xE3o encontrado",
75
- "PLAN_NOT_FOUND",
76
- 404
77
- );
81
+ throw new ApiError("Plano n\xE3o encontrado", "PLAN_NOT_FOUND", 404);
78
82
  }
79
83
  const plan = PlanSchema.parse(response.data[0]);
80
84
  await this.cache.insert(cacheKey, JSON.stringify(plan), PLANS_CACHE_TTL);
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../src/modules/plans/services/plans.service.ts"],"sourcesContent":["import 'server-only';\n\nimport { api, apiClient } from '@greatapps/common/server';\nimport { ApiError, buildQueryParams, PlanSchema } from '@greatapps/common';\nimport type { ApiPaginatedActionResult, PaginatedSuccessResult, Plan, SuccessResult } from '@greatapps/common';\nimport greatCache from '@greatapps/cache';\n\nexport type ListPlansParams = {\n sort?: string;\n active?: boolean;\n search?: string;\n id_account?: string;\n};\n\nconst PLANS_CACHE_TTL = 604800;\n\nclass PlansService {\n private cache = new greatCache({\n service: 'plans',\n version: '1.0.2',\n domain: 'whitelabel-cache.greatapps.com.br',\n ambient: process.env.NODE_ENV || 'development',\n });\n\n private buildCacheKey(key: string, params?: ListPlansParams): string {\n const sort = params?.sort ?? 'id:ASC';\n const active = params?.active ?? '';\n const search = params?.search ?? '';\n return `${key}-${sort}-${active}-${search}-v2`;\n }\n\n /**\n * Lista planos do whitelabel.\n * Exemplo:\n * GET /{id_wl}/plans?active=true&search=client&sort=id:ASC\n */\n async listPlans(params?: ListPlansParams): Promise<PaginatedSuccessResult<Plan>> {\n const cacheKey = this.buildCacheKey('plans', params);\n\n const cachedData = await this.cache.select(cacheKey);\n\n if (cachedData.status == 1 && 'data' in cachedData && cachedData.data) {\n const data = JSON.parse(cachedData.data) as Plan[];\n return { data, total: data.length, success: true };\n }\n\n const query = buildQueryParams({\n sort: params?.sort ?? 'id:ASC',\n active: params?.active,\n search: params?.search,\n id_account: params?.id_account\n });\n const url = `/plans${query ? `?${query}` : ''}`;\n\n const response = await api.apps.get<ApiPaginatedActionResult<Plan[]>>(url);\n\n if (response.status === 0) {\n throw new ApiError(\n (response as { message?: string }).message || 'Erro ao listar planos',\n 'LIST_PLANS_FAILED',\n 400\n );\n }\n\n const rawData = (response as { data?: unknown }).data;\n const data = Array.isArray(rawData)\n ? rawData.map((item) => PlanSchema.parse(item))\n : [];\n\n await this.cache.insert(cacheKey, JSON.stringify(data), PLANS_CACHE_TTL);\n\n return {\n data,\n total: response.total,\n success: true,\n } satisfies PaginatedSuccessResult<Plan>;\n }\n\n\n async findById(idPlan: number | string, id_account: string): Promise<SuccessResult<Plan>> {\n const cacheKey = this.buildCacheKey(`plan-${idPlan}-${id_account}`);\n\n const cachedData = await this.cache.select(cacheKey);\n \n if (cachedData.status == 1 && 'data' in cachedData && cachedData.data) {\n const data = JSON.parse(cachedData.data) as Plan;\n return { data, success: true };\n }\n\n const response = await api.apps.get<ApiPaginatedActionResult<Plan>>(\n `/plans/${idPlan}?type=client&id_account=${id_account}`\n );\n\n if (response.status === 0) {\n throw new ApiError(\n response.message || 'Erro ao buscar plano',\n 'FIND_PLAN_FAILED',\n 400\n );\n }\n\n if (!response.data?.length) {\n throw new ApiError(\n 'Plano não encontrado',\n 'PLAN_NOT_FOUND',\n 404\n );\n }\n\n const plan = PlanSchema.parse(response.data[0]);\n\n await this.cache.insert(cacheKey, JSON.stringify(plan), PLANS_CACHE_TTL);\n\n return {\n success: true,\n data: plan,\n };\n }\n}\n\nexport const plansService = new PlansService();\n"],"mappings":"AAAA,OAAO;AAEP,SAAS,WAAsB;AAC/B,SAAS,UAAU,kBAAkB,kBAAkB;AAEvD,OAAO,gBAAgB;AASvB,MAAM,kBAAkB;AAExB,MAAM,aAAa;AAAA,EACT,QAAQ,IAAI,WAAW;AAAA,IAC7B,SAAS;AAAA,IACT,SAAS;AAAA,IACT,QAAQ;AAAA,IACR,SAAS,QAAQ,IAAI,YAAY;AAAA,EACnC,CAAC;AAAA,EAEO,cAAc,KAAa,QAAkC;AACnE,UAAM,OAAO,QAAQ,QAAQ;AAC7B,UAAM,SAAS,QAAQ,UAAU;AACjC,UAAM,SAAS,QAAQ,UAAU;AACjC,WAAO,GAAG,GAAG,IAAI,IAAI,IAAI,MAAM,IAAI,MAAM;AAAA,EAC3C;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EAOA,MAAM,UAAU,QAAiE;AAC/E,UAAM,WAAW,KAAK,cAAc,SAAS,MAAM;AAEnD,UAAM,aAAa,MAAM,KAAK,MAAM,OAAO,QAAQ;AAEnD,QAAI,WAAW,UAAU,KAAK,UAAU,cAAc,WAAW,MAAM;AACrE,YAAMA,QAAO,KAAK,MAAM,WAAW,IAAI;AACvC,aAAO,EAAE,MAAAA,OAAM,OAAOA,MAAK,QAAQ,SAAS,KAAK;AAAA,IACnD;AAEA,UAAM,QAAQ,iBAAiB;AAAA,MAC7B,MAAM,QAAQ,QAAQ;AAAA,MACtB,QAAQ,QAAQ;AAAA,MAChB,QAAQ,QAAQ;AAAA,MAChB,YAAY,QAAQ;AAAA,IACtB,CAAC;AACD,UAAM,MAAM,SAAS,QAAQ,IAAI,KAAK,KAAK,EAAE;AAE7C,UAAM,WAAW,MAAM,IAAI,KAAK,IAAsC,GAAG;AAEzE,QAAI,SAAS,WAAW,GAAG;AACzB,YAAM,IAAI;AAAA,QACP,SAAkC,WAAW;AAAA,QAC9C;AAAA,QACA;AAAA,MACF;AAAA,IACF;AAEA,UAAM,UAAW,SAAgC;AACjD,UAAM,OAAO,MAAM,QAAQ,OAAO,IAC9B,QAAQ,IAAI,CAAC,SAAS,WAAW,MAAM,IAAI,CAAC,IAC5C,CAAC;AAEL,UAAM,KAAK,MAAM,OAAO,UAAU,KAAK,UAAU,IAAI,GAAG,eAAe;AAEvE,WAAO;AAAA,MACL;AAAA,MACA,OAAO,SAAS;AAAA,MAChB,SAAS;AAAA,IACX;AAAA,EACF;AAAA,EAGA,MAAM,SAAS,QAAyB,YAAkD;AACxF,UAAM,WAAW,KAAK,cAAc,QAAQ,MAAM,IAAI,UAAU,EAAE;AAElE,UAAM,aAAa,MAAM,KAAK,MAAM,OAAO,QAAQ;AAEnD,QAAI,WAAW,UAAU,KAAK,UAAU,cAAc,WAAW,MAAM;AACrE,YAAM,OAAO,KAAK,MAAM,WAAW,IAAI;AACvC,aAAO,EAAE,MAAM,SAAS,KAAK;AAAA,IAC/B;AAEA,UAAM,WAAW,MAAM,IAAI,KAAK;AAAA,MAC9B,UAAU,MAAM,2BAA2B,UAAU;AAAA,IACvD;AAEA,QAAI,SAAS,WAAW,GAAG;AACzB,YAAM,IAAI;AAAA,QACR,SAAS,WAAW;AAAA,QACpB;AAAA,QACA;AAAA,MACF;AAAA,IACF;AAEA,QAAI,CAAC,SAAS,MAAM,QAAQ;AAC1B,YAAM,IAAI;AAAA,QACR;AAAA,QACA;AAAA,QACA;AAAA,MACF;AAAA,IACF;AAEA,UAAM,OAAO,WAAW,MAAM,SAAS,KAAK,CAAC,CAAC;AAE9C,UAAM,KAAK,MAAM,OAAO,UAAU,KAAK,UAAU,IAAI,GAAG,eAAe;AAEvE,WAAO;AAAA,MACL,SAAS;AAAA,MACT,MAAM;AAAA,IACR;AAAA,EACF;AACF;AAEO,MAAM,eAAe,IAAI,aAAa;","names":["data"]}
1
+ {"version":3,"sources":["../../../../src/modules/plans/services/plans.service.ts"],"sourcesContent":["import \"server-only\";\n\nimport { api, apiClient, getUserContext } from \"@greatapps/common/server\";\nimport { ApiError, buildQueryParams, PlanSchema } from \"@greatapps/common\";\nimport type {\n ApiPaginatedActionResult,\n PaginatedSuccessResult,\n Plan,\n SuccessResult,\n} from \"@greatapps/common\";\nimport greatCache from \"@greatapps/cache\";\n\nexport type ListPlansParams = {\n sort?: string;\n active?: boolean;\n search?: string;\n};\n\nconst PLANS_CACHE_TTL = 604800;\n\nclass PlansService {\n private cache = new greatCache({\n service: \"plans\",\n version: \"1.0.2\",\n domain: \"whitelabel-cache.greatapps.com.br\",\n ambient: process.env.NODE_ENV || \"development\",\n });\n\n private buildCacheKey(key: string, params?: Record<string, unknown>): string {\n const sort = params?.sort ?? \"id:ASC\";\n const active = params?.active ?? \"\";\n const search = params?.search ?? \"\";\n return `${key}-${sort}-${active}-${search}-v2`;\n }\n\n /**\n * Lista planos do whitelabel.\n * Exemplo:\n * GET /{id_wl}/plans?active=true&search=client&sort=id:ASC\n */\n async listPlans(\n params?: ListPlansParams,\n ): Promise<PaginatedSuccessResult<Plan>> {\n const { id_account } = await getUserContext();\n const cacheKey = this.buildCacheKey(\"plans\", {\n ...params,\n id_account,\n });\n\n const cachedData = await this.cache.select(cacheKey);\n\n if (cachedData.status == 1 && \"data\" in cachedData && cachedData.data) {\n const data = JSON.parse(cachedData.data) as Plan[];\n return { data, total: data.length, success: true };\n }\n\n const query = buildQueryParams({\n sort: params?.sort ?? \"id:ASC\",\n active: params?.active,\n search: params?.search,\n id_account: id_account,\n });\n const url = `/plans${query ? `?${query}` : \"\"}`;\n\n const response = await api.apps.get<ApiPaginatedActionResult<Plan[]>>(url);\n\n if (response.status === 0) {\n throw new ApiError(\n (response as { message?: string }).message || \"Erro ao listar planos\",\n \"LIST_PLANS_FAILED\",\n 400,\n );\n }\n\n const rawData = (response as { data?: unknown }).data;\n const data = Array.isArray(rawData)\n ? rawData.map((item) => PlanSchema.parse(item))\n : [];\n\n await this.cache.insert(cacheKey, JSON.stringify(data), PLANS_CACHE_TTL);\n\n return {\n data,\n total: response.total,\n success: true,\n } satisfies PaginatedSuccessResult<Plan>;\n }\n\n async findById(idPlan: number | string): Promise<SuccessResult<Plan>> {\n const { id_account } = await getUserContext();\n const cacheKey = this.buildCacheKey(`plan-${idPlan}`, {\n id_account: String(id_account),\n });\n\n const cachedData = await this.cache.select(cacheKey);\n\n if (cachedData.status == 1 && \"data\" in cachedData && cachedData.data) {\n const data = JSON.parse(cachedData.data) as Plan;\n return { data, success: true };\n }\n\n const query = buildQueryParams({ type: \"client\", id_account });\n\n const response = await api.apps.get<ApiPaginatedActionResult<Plan>>(\n `/plans/${idPlan}?${query}`,\n );\n\n if (response.status === 0) {\n throw new ApiError(\n response.message || \"Erro ao buscar plano\",\n \"FIND_PLAN_FAILED\",\n 400,\n );\n }\n\n if (!response.data?.length) {\n throw new ApiError(\"Plano não encontrado\", \"PLAN_NOT_FOUND\", 404);\n }\n\n const plan = PlanSchema.parse(response.data[0]);\n\n await this.cache.insert(cacheKey, JSON.stringify(plan), PLANS_CACHE_TTL);\n\n return {\n success: true,\n data: plan,\n };\n }\n}\n\nexport const plansService = new PlansService();\n"],"mappings":"AAAA,OAAO;AAEP,SAAS,KAAgB,sBAAsB;AAC/C,SAAS,UAAU,kBAAkB,kBAAkB;AAOvD,OAAO,gBAAgB;AAQvB,MAAM,kBAAkB;AAExB,MAAM,aAAa;AAAA,EACT,QAAQ,IAAI,WAAW;AAAA,IAC7B,SAAS;AAAA,IACT,SAAS;AAAA,IACT,QAAQ;AAAA,IACR,SAAS,QAAQ,IAAI,YAAY;AAAA,EACnC,CAAC;AAAA,EAEO,cAAc,KAAa,QAA0C;AAC3E,UAAM,OAAO,QAAQ,QAAQ;AAC7B,UAAM,SAAS,QAAQ,UAAU;AACjC,UAAM,SAAS,QAAQ,UAAU;AACjC,WAAO,GAAG,GAAG,IAAI,IAAI,IAAI,MAAM,IAAI,MAAM;AAAA,EAC3C;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EAOA,MAAM,UACJ,QACuC;AACvC,UAAM,EAAE,WAAW,IAAI,MAAM,eAAe;AAC5C,UAAM,WAAW,KAAK,cAAc,SAAS;AAAA,MAC3C,GAAG;AAAA,MACH;AAAA,IACF,CAAC;AAED,UAAM,aAAa,MAAM,KAAK,MAAM,OAAO,QAAQ;AAEnD,QAAI,WAAW,UAAU,KAAK,UAAU,cAAc,WAAW,MAAM;AACrE,YAAMA,QAAO,KAAK,MAAM,WAAW,IAAI;AACvC,aAAO,EAAE,MAAAA,OAAM,OAAOA,MAAK,QAAQ,SAAS,KAAK;AAAA,IACnD;AAEA,UAAM,QAAQ,iBAAiB;AAAA,MAC7B,MAAM,QAAQ,QAAQ;AAAA,MACtB,QAAQ,QAAQ;AAAA,MAChB,QAAQ,QAAQ;AAAA,MAChB;AAAA,IACF,CAAC;AACD,UAAM,MAAM,SAAS,QAAQ,IAAI,KAAK,KAAK,EAAE;AAE7C,UAAM,WAAW,MAAM,IAAI,KAAK,IAAsC,GAAG;AAEzE,QAAI,SAAS,WAAW,GAAG;AACzB,YAAM,IAAI;AAAA,QACP,SAAkC,WAAW;AAAA,QAC9C;AAAA,QACA;AAAA,MACF;AAAA,IACF;AAEA,UAAM,UAAW,SAAgC;AACjD,UAAM,OAAO,MAAM,QAAQ,OAAO,IAC9B,QAAQ,IAAI,CAAC,SAAS,WAAW,MAAM,IAAI,CAAC,IAC5C,CAAC;AAEL,UAAM,KAAK,MAAM,OAAO,UAAU,KAAK,UAAU,IAAI,GAAG,eAAe;AAEvE,WAAO;AAAA,MACL;AAAA,MACA,OAAO,SAAS;AAAA,MAChB,SAAS;AAAA,IACX;AAAA,EACF;AAAA,EAEA,MAAM,SAAS,QAAuD;AACpE,UAAM,EAAE,WAAW,IAAI,MAAM,eAAe;AAC5C,UAAM,WAAW,KAAK,cAAc,QAAQ,MAAM,IAAI;AAAA,MACpD,YAAY,OAAO,UAAU;AAAA,IAC/B,CAAC;AAED,UAAM,aAAa,MAAM,KAAK,MAAM,OAAO,QAAQ;AAEnD,QAAI,WAAW,UAAU,KAAK,UAAU,cAAc,WAAW,MAAM;AACrE,YAAM,OAAO,KAAK,MAAM,WAAW,IAAI;AACvC,aAAO,EAAE,MAAM,SAAS,KAAK;AAAA,IAC/B;AAEA,UAAM,QAAQ,iBAAiB,EAAE,MAAM,UAAU,WAAW,CAAC;AAE7D,UAAM,WAAW,MAAM,IAAI,KAAK;AAAA,MAC9B,UAAU,MAAM,IAAI,KAAK;AAAA,IAC3B;AAEA,QAAI,SAAS,WAAW,GAAG;AACzB,YAAM,IAAI;AAAA,QACR,SAAS,WAAW;AAAA,QACpB;AAAA,QACA;AAAA,MACF;AAAA,IACF;AAEA,QAAI,CAAC,SAAS,MAAM,QAAQ;AAC1B,YAAM,IAAI,SAAS,2BAAwB,kBAAkB,GAAG;AAAA,IAClE;AAEA,UAAM,OAAO,WAAW,MAAM,SAAS,KAAK,CAAC,CAAC;AAE9C,UAAM,KAAK,MAAM,OAAO,UAAU,KAAK,UAAU,IAAI,GAAG,eAAe;AAEvE,WAAO;AAAA,MACL,SAAS;AAAA,MACT,MAAM;AAAA,IACR;AAAA,EACF;AACF;AAEO,MAAM,eAAe,IAAI,aAAa;","names":["data"]}
package/dist/server.mjs CHANGED
@@ -53,6 +53,8 @@ import { getClientInfoFromRequest } from "./infra/utils/client-info";
53
53
  import { getUserContext } from "./modules/auth/utils/get-user-context";
54
54
  import { buildWlOverride, buildWlOverrideFromJwt } from "./modules/auth/utils/build-wl-override";
55
55
  import { safeServerAction } from "./utils/safeServerAction";
56
+ import { processImageAction } from "./modules/images/actions/process-image.action";
57
+ import { processImage } from "./modules/images/services/image-processing.service";
56
58
  export {
57
59
  ApiClient,
58
60
  accountService,
@@ -94,6 +96,8 @@ export {
94
96
  listProjectUsersAction,
95
97
  listSubscriptionsAction,
96
98
  plansService,
99
+ processImage,
100
+ processImageAction,
97
101
  projectUsersService,
98
102
  removeProjectUserAction,
99
103
  requestEmailChangeAction,
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/server.ts"],"sourcesContent":["import 'server-only';\r\n\r\n// API Client\r\nexport { ApiClient, api, apiClient } from './infra/api/client';\r\n\r\n// Services\r\nexport { authService } from './modules/auth/services/auth.service';\r\nexport { whitelabelService } from './modules/whitelabel/services/whitelabel.service';\r\nexport { revalidateWhitelabelAction } from './modules/whitelabel/actions/revalidate-whitelabel.action';\r\nexport { assertManagementPermission } from './modules/auth/utils/assert-management-permission';\r\nexport { assertManagementSubscription } from './modules/auth/utils/assert-management-subscription';\r\nexport { assertSubscriptionAddon } from './modules/auth/utils/assert-subscription-addon';\r\nexport { assertPaidSubscription } from './modules/auth/utils/assert-paid-subscription';\r\nexport { subscriptionsService } from './modules/subscriptions/services/subscriptions.service';\r\nexport { plansService } from './modules/plans/services/plans.service';\r\nexport { cardsService } from './modules/cards/services/cards.service';\r\nexport { iaCreditsService } from './modules/ia-credits/services/ia-credits.service';\r\n\r\n// Actions\r\nexport { findWhitelabel } from './modules/whitelabel/actions/find-whitelabel.action';\r\nexport { validateSessionAction } from './modules/auth/actions/validate-session.action';\r\nexport { findUserById } from './modules/users/action/find-user-by-id.action';\r\nexport { findCurrentAccount } from './modules/accounts/actions/find-current-account.action';\r\nexport { listSubscriptionsAction } from './modules/subscriptions/actions/list-subscriptions.action';\r\nexport { findPlanByIdAction } from './modules/plans/actions/find-plan-by-id.action';\r\nexport { listPlansAction } from './modules/plans/actions/list-plans.action';\r\nexport { calculateSubscriptionAction } from './modules/subscriptions/actions/calculate-subscription.action';\r\nexport { updateSubscriptionPlanAction } from './modules/subscriptions/actions/update-subscription-plan.action';\r\nexport { listCardsAction } from './modules/cards/actions/list-cards.action';\r\nexport { createCardAction } from './modules/cards/actions/create-card.action';\r\nexport { resolvePlanExtrasPrices } from './modules/subscriptions/utils/resolve-plan-extras-prices';\r\nexport { hasSubscriptionExpired } from './modules/subscriptions/utils/has-subscription-expired';\r\nexport { listIaCreditsAction } from './modules/ia-credits/actions/list-ia-credits.action';\r\n\r\n// Project Users Services & Actions (server-only)\r\nexport { projectUsersService } from './modules/projects/services/project-users.service';\r\nexport { listProjectUsersAction } from './modules/projects/actions/list-project-users.action';\r\nexport { listAvailableUsersAction } from './modules/projects/actions/list-available-users.action';\r\nexport { addProjectUserAction } from './modules/projects/actions/add-project-user.action';\r\nexport { removeProjectUserAction } from './modules/projects/actions/remove-project-user.action';\r\n\r\n// Account Services & Actions (server-only)\r\nexport { accountService } from './modules/accounts/services/account.service';\r\nexport { twoFactorService } from './modules/accounts/services/two-factor.service';\r\nexport {\r\n listAccountUsersAction,\r\n updateUserAction,\r\n updateAccountAction,\r\n updateAccountUserByIdAction,\r\n deleteAccountUserAction,\r\n deleteAccountAction,\r\n createAccountUserAction,\r\n changePasswordAction,\r\n requestEmailChangeAction,\r\n confirmEmailChangeAction,\r\n requestPhoneChangeAction,\r\n confirmPhoneChangeAction,\r\n generateTwoFactorAction,\r\n confirmTwoFactorAction,\r\n disableTwoFactorAction,\r\n} from './modules/accounts/actions/account-management.action';\r\n\r\n// Server Utils\r\nexport { getClientInfoFromRequest } from './infra/utils/client-info';\r\nexport { getUserContext } from './modules/auth/utils/get-user-context';\r\nexport { buildWlOverride, buildWlOverrideFromJwt } from './modules/auth/utils/build-wl-override';\r\nexport { safeServerAction } from './utils/safeServerAction';\r\n"],"mappings":"AAAA,OAAO;AAGP,SAAS,WAAW,KAAK,iBAAiB;AAG1C,SAAS,mBAAmB;AAC5B,SAAS,yBAAyB;AAClC,SAAS,kCAAkC;AAC3C,SAAS,kCAAkC;AAC3C,SAAS,oCAAoC;AAC7C,SAAS,+BAA+B;AACxC,SAAS,8BAA8B;AACvC,SAAS,4BAA4B;AACrC,SAAS,oBAAoB;AAC7B,SAAS,oBAAoB;AAC7B,SAAS,wBAAwB;AAGjC,SAAS,sBAAsB;AAC/B,SAAS,6BAA6B;AACtC,SAAS,oBAAoB;AAC7B,SAAS,0BAA0B;AACnC,SAAS,+BAA+B;AACxC,SAAS,0BAA0B;AACnC,SAAS,uBAAuB;AAChC,SAAS,mCAAmC;AAC5C,SAAS,oCAAoC;AAC7C,SAAS,uBAAuB;AAChC,SAAS,wBAAwB;AACjC,SAAS,+BAA+B;AACxC,SAAS,8BAA8B;AACvC,SAAS,2BAA2B;AAGpC,SAAS,2BAA2B;AACpC,SAAS,8BAA8B;AACvC,SAAS,gCAAgC;AACzC,SAAS,4BAA4B;AACrC,SAAS,+BAA+B;AAGxC,SAAS,sBAAsB;AAC/B,SAAS,wBAAwB;AACjC;AAAA,EACE;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,OACK;AAGP,SAAS,gCAAgC;AACzC,SAAS,sBAAsB;AAC/B,SAAS,iBAAiB,8BAA8B;AACxD,SAAS,wBAAwB;","names":[]}
1
+ {"version":3,"sources":["../src/server.ts"],"sourcesContent":["import 'server-only';\r\n\r\n// API Client\r\nexport { ApiClient, api, apiClient } from './infra/api/client';\r\n\r\n// Services\r\nexport { authService } from './modules/auth/services/auth.service';\r\nexport { whitelabelService } from './modules/whitelabel/services/whitelabel.service';\r\nexport { revalidateWhitelabelAction } from './modules/whitelabel/actions/revalidate-whitelabel.action';\r\nexport { assertManagementPermission } from './modules/auth/utils/assert-management-permission';\r\nexport { assertManagementSubscription } from './modules/auth/utils/assert-management-subscription';\r\nexport { assertSubscriptionAddon } from './modules/auth/utils/assert-subscription-addon';\r\nexport { assertPaidSubscription } from './modules/auth/utils/assert-paid-subscription';\r\nexport { subscriptionsService } from './modules/subscriptions/services/subscriptions.service';\r\nexport { plansService } from './modules/plans/services/plans.service';\r\nexport { cardsService } from './modules/cards/services/cards.service';\r\nexport { iaCreditsService } from './modules/ia-credits/services/ia-credits.service';\r\n\r\n// Actions\r\nexport { findWhitelabel } from './modules/whitelabel/actions/find-whitelabel.action';\r\nexport { validateSessionAction } from './modules/auth/actions/validate-session.action';\r\nexport { findUserById } from './modules/users/action/find-user-by-id.action';\r\nexport { findCurrentAccount } from './modules/accounts/actions/find-current-account.action';\r\nexport { listSubscriptionsAction } from './modules/subscriptions/actions/list-subscriptions.action';\r\nexport { findPlanByIdAction } from './modules/plans/actions/find-plan-by-id.action';\r\nexport { listPlansAction } from './modules/plans/actions/list-plans.action';\r\nexport { calculateSubscriptionAction } from './modules/subscriptions/actions/calculate-subscription.action';\r\nexport { updateSubscriptionPlanAction } from './modules/subscriptions/actions/update-subscription-plan.action';\r\nexport { listCardsAction } from './modules/cards/actions/list-cards.action';\r\nexport { createCardAction } from './modules/cards/actions/create-card.action';\r\nexport { resolvePlanExtrasPrices } from './modules/subscriptions/utils/resolve-plan-extras-prices';\r\nexport { hasSubscriptionExpired } from './modules/subscriptions/utils/has-subscription-expired';\r\nexport { listIaCreditsAction } from './modules/ia-credits/actions/list-ia-credits.action';\r\n\r\n// Project Users Services & Actions (server-only)\r\nexport { projectUsersService } from './modules/projects/services/project-users.service';\r\nexport { listProjectUsersAction } from './modules/projects/actions/list-project-users.action';\r\nexport { listAvailableUsersAction } from './modules/projects/actions/list-available-users.action';\r\nexport { addProjectUserAction } from './modules/projects/actions/add-project-user.action';\r\nexport { removeProjectUserAction } from './modules/projects/actions/remove-project-user.action';\r\n\r\n// Account Services & Actions (server-only)\r\nexport { accountService } from './modules/accounts/services/account.service';\r\nexport { twoFactorService } from './modules/accounts/services/two-factor.service';\r\nexport {\r\n listAccountUsersAction,\r\n updateUserAction,\r\n updateAccountAction,\r\n updateAccountUserByIdAction,\r\n deleteAccountUserAction,\r\n deleteAccountAction,\r\n createAccountUserAction,\r\n changePasswordAction,\r\n requestEmailChangeAction,\r\n confirmEmailChangeAction,\r\n requestPhoneChangeAction,\r\n confirmPhoneChangeAction,\r\n generateTwoFactorAction,\r\n confirmTwoFactorAction,\r\n disableTwoFactorAction,\r\n} from './modules/accounts/actions/account-management.action';\r\n\r\n// Server Utils\r\nexport { getClientInfoFromRequest } from './infra/utils/client-info';\r\nexport { getUserContext } from './modules/auth/utils/get-user-context';\r\nexport { buildWlOverride, buildWlOverrideFromJwt } from './modules/auth/utils/build-wl-override';\r\nexport { safeServerAction } from './utils/safeServerAction';\r\n\r\n// Image Processing (server-side)\r\nexport { processImageAction } from './modules/images/actions/process-image.action';\r\nexport { processImage } from './modules/images/services/image-processing.service';\r\nexport type {\r\n ProcessImageInput,\r\n ProcessImageOutput,\r\n ProcessImageActionInput,\r\n ProcessImageActionOutput,\r\n} from './modules/images/types/image.type';\r\n"],"mappings":"AAAA,OAAO;AAGP,SAAS,WAAW,KAAK,iBAAiB;AAG1C,SAAS,mBAAmB;AAC5B,SAAS,yBAAyB;AAClC,SAAS,kCAAkC;AAC3C,SAAS,kCAAkC;AAC3C,SAAS,oCAAoC;AAC7C,SAAS,+BAA+B;AACxC,SAAS,8BAA8B;AACvC,SAAS,4BAA4B;AACrC,SAAS,oBAAoB;AAC7B,SAAS,oBAAoB;AAC7B,SAAS,wBAAwB;AAGjC,SAAS,sBAAsB;AAC/B,SAAS,6BAA6B;AACtC,SAAS,oBAAoB;AAC7B,SAAS,0BAA0B;AACnC,SAAS,+BAA+B;AACxC,SAAS,0BAA0B;AACnC,SAAS,uBAAuB;AAChC,SAAS,mCAAmC;AAC5C,SAAS,oCAAoC;AAC7C,SAAS,uBAAuB;AAChC,SAAS,wBAAwB;AACjC,SAAS,+BAA+B;AACxC,SAAS,8BAA8B;AACvC,SAAS,2BAA2B;AAGpC,SAAS,2BAA2B;AACpC,SAAS,8BAA8B;AACvC,SAAS,gCAAgC;AACzC,SAAS,4BAA4B;AACrC,SAAS,+BAA+B;AAGxC,SAAS,sBAAsB;AAC/B,SAAS,wBAAwB;AACjC;AAAA,EACE;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,OACK;AAGP,SAAS,gCAAgC;AACzC,SAAS,sBAAsB;AAC/B,SAAS,iBAAiB,8BAA8B;AACxD,SAAS,wBAAwB;AAGjC,SAAS,0BAA0B;AACnC,SAAS,oBAAoB;","names":[]}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@greatapps/common",
3
- "version": "1.1.447",
3
+ "version": "1.1.449",
4
4
  "description": "Shared library for GreatApps frontend applications",
5
5
  "main": "./dist/index.mjs",
6
6
  "types": "./src/index.ts",
@@ -31,6 +31,7 @@
31
31
  "author": "",
32
32
  "license": "ISC",
33
33
  "dependencies": {
34
+ "@cf-wasm/photon": "^0.3.4",
34
35
  "@greatapps/cache": "^2.0.1",
35
36
  "@radix-ui/react-accordion": "^1.2.12",
36
37
  "@radix-ui/react-checkbox": "^1.3.3",
@@ -47,6 +48,7 @@
47
48
  "@tanstack/query-broadcast-client-experimental": "^5.95.2",
48
49
  "@tanstack/react-query-persist-client": "^5.95.2",
49
50
  "axios": "^1.13.6",
51
+ "browser-image-compression": "^2.0.2",
50
52
  "class-variance-authority": "^0.7.1",
51
53
  "clsx": "^2.1.1",
52
54
  "cmdk": "^1.1.1",
@@ -55,6 +57,7 @@
55
57
  "input-otp": "^1.4.2",
56
58
  "libphonenumber-js": "^1.12.40",
57
59
  "react-day-picker": "^9.14.0",
60
+ "react-image-crop": "^11.0.10",
58
61
  "server-only": "^0.0.1",
59
62
  "sonner": ">=2.0.0",
60
63
  "tailwind-merge": "^3.5.0",
@@ -9,7 +9,7 @@ import { useCreateCard } from '../../../modules/cards/hooks/create-card.hook';
9
9
  import { useUpdateAccount } from '../../../modules/accounts/hooks/useAccountManagement';
10
10
  import { CardFormFields, BillingFormFields, cardFormSchema } from './CardFormFields';
11
11
  import type { CardFormData, StripeElementsStatus } from './CardFormFields';
12
- import { IconX } from '@tabler/icons-react';
12
+ import { IconLoader2, IconX } from '@tabler/icons-react';
13
13
  import { useForm } from 'react-hook-form';
14
14
  import { zodResolver } from '@hookform/resolvers/zod';
15
15
  import { CardNumberElement, useElements, useStripe } from '@stripe/react-stripe-js';
@@ -248,6 +248,7 @@ export default function AddCardModal() {
248
248
  Voltar
249
249
  </Button>
250
250
  <Button type="submit" className="h-10!" disabled={isSubmitting || !isStep2Valid}>
251
+ {isSubmitting && <IconLoader2 className="size-4 animate-spin" />}
251
252
  {isSubmitting ? 'Adicionando cartão...' : 'Adicionar cartão'}
252
253
  </Button>
253
254
  </>
@@ -0,0 +1,97 @@
1
+ 'use client'
2
+
3
+ import { useRef, useState } from 'react'
4
+ import ReactCrop, {
5
+ type Crop,
6
+ centerCrop,
7
+ makeAspectCrop,
8
+ } from 'react-image-crop'
9
+ import 'react-image-crop/dist/ReactCrop.css'
10
+ import {
11
+ Dialog,
12
+ DialogContent,
13
+ DialogFooter,
14
+ DialogHeader,
15
+ DialogTitle,
16
+ } from '../../ui/overlay/Dialog'
17
+ import { Button } from '../../ui/buttons/Button'
18
+
19
+ interface ImageCropModalProps {
20
+ open: boolean
21
+ onOpenChange: (open: boolean) => void
22
+ imageSrc: string
23
+ aspectRatio: number
24
+ onConfirm: (image: HTMLImageElement, crop: Crop) => void
25
+ }
26
+
27
+ export function ImageCropModal({
28
+ open,
29
+ onOpenChange,
30
+ imageSrc,
31
+ aspectRatio,
32
+ onConfirm,
33
+ }: ImageCropModalProps) {
34
+ const imgRef = useRef<HTMLImageElement>(null)
35
+ const [crop, setCrop] = useState<Crop>()
36
+
37
+ const onImageLoad = (e: React.SyntheticEvent<HTMLImageElement>) => {
38
+ const { naturalWidth, naturalHeight } = e.currentTarget
39
+
40
+ const initialCrop = centerCrop(
41
+ makeAspectCrop(
42
+ { unit: '%', width: 90 },
43
+ aspectRatio,
44
+ naturalWidth,
45
+ naturalHeight
46
+ ),
47
+ naturalWidth,
48
+ naturalHeight
49
+ )
50
+
51
+ setCrop(initialCrop)
52
+ }
53
+
54
+ const handleConfirm = () => {
55
+ if (imgRef.current && crop) {
56
+ onConfirm(imgRef.current, crop)
57
+ }
58
+ }
59
+
60
+ return (
61
+ <Dialog open={open} onOpenChange={onOpenChange}>
62
+ <DialogContent className="max-w-2xl">
63
+ <DialogHeader>
64
+ <DialogTitle>Ajustar imagem</DialogTitle>
65
+ </DialogHeader>
66
+
67
+ <div className="flex justify-center py-4">
68
+ <ReactCrop
69
+ crop={crop}
70
+ onChange={(c) => setCrop(c)}
71
+ aspect={aspectRatio}
72
+ className="max-h-[60vh]"
73
+ >
74
+ <img
75
+ ref={imgRef}
76
+ src={imageSrc}
77
+ alt="Crop"
78
+ onLoad={onImageLoad}
79
+ className="max-h-[60vh] object-contain"
80
+ />
81
+ </ReactCrop>
82
+ </div>
83
+
84
+ <p className="paragraph-small-regular text-gray-500 text-center">
85
+ Arraste para ajustar a área de corte
86
+ </p>
87
+
88
+ <DialogFooter>
89
+ <Button variant="secondary" onClick={() => onOpenChange(false)}>
90
+ Cancelar
91
+ </Button>
92
+ <Button onClick={handleConfirm}>Confirmar</Button>
93
+ </DialogFooter>
94
+ </DialogContent>
95
+ </Dialog>
96
+ )
97
+ }