@hobenakicoffee/libraries 0.0.10 → 0.0.11
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/package.json +6 -2
- package/src/utils/format-amount.test.ts +30 -0
- package/src/utils/format-amount.ts +19 -0
- package/src/utils/format-date.test.ts +19 -0
- package/src/utils/format-date.ts +13 -0
- package/src/utils/format-plain-text.test.ts +36 -0
- package/src/utils/format-plain-text.ts +58 -0
- package/src/utils/get-social-handle.test.ts +32 -0
- package/src/utils/get-social-handle.ts +66 -0
- package/src/utils/get-user-page-link.test.ts +10 -0
- package/src/utils/get-user-page-link.ts +6 -0
- package/src/utils/index.ts +11 -0
- package/src/utils/open-to-new-window.test.ts +34 -0
- package/src/utils/open-to-new-window.ts +5 -0
- package/src/utils/post-to-facebook.test.ts +43 -0
- package/src/utils/post-to-facebook.ts +29 -0
- package/src/utils/post-to-instagram.test.ts +56 -0
- package/src/utils/post-to-instagram.ts +42 -0
- package/src/utils/post-to-linkedin.test.ts +43 -0
- package/src/utils/post-to-linkedin.ts +29 -0
- package/src/utils/post-to-x.test.ts +45 -0
- package/src/utils/post-to-x.ts +26 -0
- package/src/utils/qr-svg-utils.test.ts +104 -0
- package/src/utils/qr-svg-utils.ts +182 -0
package/package.json
CHANGED
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hobenakicoffee/libraries",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.11",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"types": "src/index.ts",
|
|
6
6
|
"exports": {
|
|
7
7
|
".": "./src/index.ts",
|
|
8
|
-
"./constants": "./src/constants/index.ts"
|
|
8
|
+
"./constants": "./src/constants/index.ts",
|
|
9
|
+
"./utils": "./src/utils/index.ts"
|
|
9
10
|
},
|
|
10
11
|
"files": [
|
|
11
12
|
"src",
|
|
@@ -30,5 +31,8 @@
|
|
|
30
31
|
},
|
|
31
32
|
"publishConfig": {
|
|
32
33
|
"access": "public"
|
|
34
|
+
},
|
|
35
|
+
"dependencies": {
|
|
36
|
+
"sonner": "^2.0.7"
|
|
33
37
|
}
|
|
34
38
|
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { formatAmount, formatSignedAmount } from "./format-amount";
|
|
3
|
+
|
|
4
|
+
describe("formatAmount", () => {
|
|
5
|
+
test("formats absolute number with currency symbol", () => {
|
|
6
|
+
const expected = new Intl.NumberFormat(undefined, {
|
|
7
|
+
maximumFractionDigits: 0,
|
|
8
|
+
}).format(1234);
|
|
9
|
+
|
|
10
|
+
expect(formatAmount(-1234)).toBe(`৳${expected}`);
|
|
11
|
+
});
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
describe("formatSignedAmount", () => {
|
|
15
|
+
test("uses minus sign for debit", () => {
|
|
16
|
+
const expected = new Intl.NumberFormat(undefined, {
|
|
17
|
+
maximumFractionDigits: 0,
|
|
18
|
+
}).format(2000);
|
|
19
|
+
|
|
20
|
+
expect(formatSignedAmount(2000, "debit")).toBe(`- ৳${expected}`);
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
test("uses plus sign for credit and absolute value", () => {
|
|
24
|
+
const expected = new Intl.NumberFormat(undefined, {
|
|
25
|
+
maximumFractionDigits: 0,
|
|
26
|
+
}).format(2000);
|
|
27
|
+
|
|
28
|
+
expect(formatSignedAmount(-2000, "credit")).toBe(`+ ৳${expected}`);
|
|
29
|
+
});
|
|
30
|
+
});
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
export function formatAmount(value: number) {
|
|
2
|
+
const formatted = new Intl.NumberFormat(undefined, {
|
|
3
|
+
maximumFractionDigits: 0,
|
|
4
|
+
}).format(Math.abs(value));
|
|
5
|
+
|
|
6
|
+
return `৳${formatted}`;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function formatSignedAmount(
|
|
10
|
+
value: number,
|
|
11
|
+
direction: "debit" | "credit"
|
|
12
|
+
) {
|
|
13
|
+
const formatted = new Intl.NumberFormat(undefined, {
|
|
14
|
+
maximumFractionDigits: 0,
|
|
15
|
+
}).format(Math.abs(value));
|
|
16
|
+
|
|
17
|
+
const sign = direction === "debit" ? "-" : "+";
|
|
18
|
+
return `${sign} ৳${formatted}`;
|
|
19
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { formatDate } from "./format-date";
|
|
3
|
+
|
|
4
|
+
describe("formatDate", () => {
|
|
5
|
+
test("returns dash for invalid date", () => {
|
|
6
|
+
expect(formatDate("not-a-date")).toBe("-");
|
|
7
|
+
});
|
|
8
|
+
|
|
9
|
+
test("formats a valid date", () => {
|
|
10
|
+
const input = "2026-02-13T00:00:00.000Z";
|
|
11
|
+
const expected = new Date(input).toLocaleDateString(undefined, {
|
|
12
|
+
month: "short",
|
|
13
|
+
day: "numeric",
|
|
14
|
+
year: "numeric",
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
expect(formatDate(input)).toBe(expected);
|
|
18
|
+
});
|
|
19
|
+
});
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { formatMetadataKey, formatToPlainText } from "./format-plain-text";
|
|
3
|
+
|
|
4
|
+
describe("formatToPlainText", () => {
|
|
5
|
+
test("returns empty string for null and undefined", () => {
|
|
6
|
+
expect(formatToPlainText(null)).toBe("");
|
|
7
|
+
expect(formatToPlainText(undefined)).toBe("");
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
test("formats booleans to Yes/No by default", () => {
|
|
11
|
+
expect(formatToPlainText(true)).toBe("Yes");
|
|
12
|
+
expect(formatToPlainText(false)).toBe("No");
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
test("can keep raw boolean text", () => {
|
|
16
|
+
expect(formatToPlainText(true, { formatBooleans: false })).toBe("true");
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
test("truncates long strings with ellipsis", () => {
|
|
20
|
+
expect(formatToPlainText("abcdef", { maxStringLength: 5 })).toBe("ab...");
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
test("stringifies objects and arrays", () => {
|
|
24
|
+
expect(formatToPlainText({ a: 1 })).toBe(JSON.stringify({ a: 1 }, null, 2));
|
|
25
|
+
expect(formatToPlainText(["x", "y"])).toBe(
|
|
26
|
+
JSON.stringify(["x", "y"], null, 2),
|
|
27
|
+
);
|
|
28
|
+
});
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
describe("formatMetadataKey", () => {
|
|
32
|
+
test("formats camelCase and snake_case keys", () => {
|
|
33
|
+
expect(formatMetadataKey("supporterName")).toBe("Supporter Name");
|
|
34
|
+
expect(formatMetadataKey("is_monthly")).toBe("Is monthly");
|
|
35
|
+
});
|
|
36
|
+
});
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
const CAPITALIZE_REGEX = /([A-Z])/g;
|
|
2
|
+
const SPACE_REPLACE_REGEX = /_/g;
|
|
3
|
+
const FIRST_LETTER_REGEX = /^\w/;
|
|
4
|
+
|
|
5
|
+
export function formatToPlainText(
|
|
6
|
+
value: unknown,
|
|
7
|
+
options?: {
|
|
8
|
+
formatBooleans?: boolean;
|
|
9
|
+
preserveNumbers?: boolean;
|
|
10
|
+
maxStringLength?: number;
|
|
11
|
+
}
|
|
12
|
+
): string {
|
|
13
|
+
const formatBooleans = options?.formatBooleans ?? true;
|
|
14
|
+
const preserveNumbers = options?.preserveNumbers ?? true;
|
|
15
|
+
const maxStringLength = options?.maxStringLength ?? 100;
|
|
16
|
+
|
|
17
|
+
// Handle null/undefined values
|
|
18
|
+
if (value === null || value === undefined) {
|
|
19
|
+
return "";
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// Format based on type
|
|
23
|
+
switch (typeof value) {
|
|
24
|
+
case "boolean":
|
|
25
|
+
return formatBooleans ? (value ? "Yes" : "No") : String(value);
|
|
26
|
+
|
|
27
|
+
case "number":
|
|
28
|
+
return preserveNumbers ? value.toString() : value.toString();
|
|
29
|
+
|
|
30
|
+
case "string": {
|
|
31
|
+
const stringValue = value as string;
|
|
32
|
+
return maxStringLength && stringValue.length > maxStringLength
|
|
33
|
+
? `${stringValue.substring(0, maxStringLength - 3)}...`
|
|
34
|
+
: stringValue;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
case "object":
|
|
38
|
+
if (Array.isArray(value)) {
|
|
39
|
+
return JSON.stringify(value, null, 2);
|
|
40
|
+
}
|
|
41
|
+
return JSON.stringify(value, null, 2);
|
|
42
|
+
|
|
43
|
+
default:
|
|
44
|
+
return String(value);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function capitalizeKey(keyToFormat: string): string {
|
|
49
|
+
return keyToFormat
|
|
50
|
+
.replace(CAPITALIZE_REGEX, " $1") // "supporterName" → "supporter Name"
|
|
51
|
+
.replace(SPACE_REPLACE_REGEX, " ") // "is_monthly" → "is monthly"
|
|
52
|
+
.replace(FIRST_LETTER_REGEX, (c) => c.toUpperCase()) // "supporter" → "Supporter"
|
|
53
|
+
.trim();
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function formatMetadataKey(key: string): string {
|
|
57
|
+
return capitalizeKey(key);
|
|
58
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { getSocialUrl } from "./get-social-handle";
|
|
3
|
+
import { getUserPageLink } from "./get-user-page-link";
|
|
4
|
+
|
|
5
|
+
describe("getSocialUrl", () => {
|
|
6
|
+
test("returns our platform URL when username is provided", () => {
|
|
7
|
+
expect(getSocialUrl("alice", "x", "ignored")).toBe(
|
|
8
|
+
getUserPageLink("alice"),
|
|
9
|
+
);
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
test("returns # when required fields are missing", () => {
|
|
13
|
+
expect(getSocialUrl(undefined, "x", "")).toBe("#");
|
|
14
|
+
expect(getSocialUrl(undefined, null, "name")).toBe("#");
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
test("builds social URL with sanitized handle", () => {
|
|
18
|
+
expect(getSocialUrl(undefined, "instagram", " @john doe ")).toBe(
|
|
19
|
+
"https://instagram.com/johndoe",
|
|
20
|
+
);
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
test("uses platform-specific format for youtube", () => {
|
|
24
|
+
expect(getSocialUrl(undefined, "youtube", "Jane")).toBe(
|
|
25
|
+
"https://youtube.com/@Jane",
|
|
26
|
+
);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
test("returns # for unsupported platform", () => {
|
|
30
|
+
expect(getSocialUrl(undefined, "myspace" as any, "Jane")).toBe("#");
|
|
31
|
+
});
|
|
32
|
+
});
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import type { SupporterPlatform } from "@hobenakicoffee/libraries";
|
|
2
|
+
import { getUserPageLink } from "./get-user-page-link";
|
|
3
|
+
|
|
4
|
+
const patternsToRemove = /^@/;
|
|
5
|
+
|
|
6
|
+
const sanitizeHandle = (value: string) =>
|
|
7
|
+
encodeURIComponent(
|
|
8
|
+
value.trim().replace(patternsToRemove, "").replace(/\s+/g, "")
|
|
9
|
+
);
|
|
10
|
+
|
|
11
|
+
export const getSocialUrl = (
|
|
12
|
+
ourPlatformUsername?: string | null,
|
|
13
|
+
platform?: SupporterPlatform | null,
|
|
14
|
+
supporterName?: string | null
|
|
15
|
+
) => {
|
|
16
|
+
if (ourPlatformUsername) {
|
|
17
|
+
return getUserPageLink(ourPlatformUsername);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
if (!(platform && supporterName?.trim())) {
|
|
21
|
+
return "#";
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const handle = sanitizeHandle(supporterName);
|
|
25
|
+
|
|
26
|
+
switch (platform) {
|
|
27
|
+
case "facebook":
|
|
28
|
+
return `https://facebook.com/${handle}`;
|
|
29
|
+
case "x":
|
|
30
|
+
return `https://x.com/${handle}`;
|
|
31
|
+
case "instagram":
|
|
32
|
+
return `https://instagram.com/${handle}`;
|
|
33
|
+
case "youtube":
|
|
34
|
+
return `https://youtube.com/@${handle}`;
|
|
35
|
+
case "github":
|
|
36
|
+
return `https://github.com/${handle}`;
|
|
37
|
+
case "linkedin":
|
|
38
|
+
return `https://www.linkedin.com/in/${handle}`;
|
|
39
|
+
case "twitch":
|
|
40
|
+
return `https://twitch.tv/${handle}`;
|
|
41
|
+
case "tiktok":
|
|
42
|
+
return `https://www.tiktok.com/@${handle}`;
|
|
43
|
+
case "threads":
|
|
44
|
+
return `https://www.threads.net/@${handle}`;
|
|
45
|
+
case "whatsapp":
|
|
46
|
+
return `https://wa.me/${handle}`;
|
|
47
|
+
case "telegram":
|
|
48
|
+
return `https://t.me/${handle}`;
|
|
49
|
+
case "discord":
|
|
50
|
+
return `https://discord.com/users/${handle}`;
|
|
51
|
+
case "reddit":
|
|
52
|
+
return `https://reddit.com/u/${handle}`;
|
|
53
|
+
case "pinterest":
|
|
54
|
+
return `https://pinterest.com/${handle}`;
|
|
55
|
+
case "medium":
|
|
56
|
+
return `https://medium.com/@${handle}`;
|
|
57
|
+
case "devto":
|
|
58
|
+
return `https://dev.to/${handle}`;
|
|
59
|
+
case "behance":
|
|
60
|
+
return `https://www.behance.net/${handle}`;
|
|
61
|
+
case "dribbble":
|
|
62
|
+
return `https://dribbble.com/${handle}`;
|
|
63
|
+
default:
|
|
64
|
+
return "#";
|
|
65
|
+
}
|
|
66
|
+
};
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { getUserPageLink } from "./get-user-page-link";
|
|
3
|
+
|
|
4
|
+
describe("getUserPageLink", () => {
|
|
5
|
+
test("builds user page link with sanitized username", () => {
|
|
6
|
+
const result = getUserPageLink(" @john doe ");
|
|
7
|
+
|
|
8
|
+
expect(result.endsWith("/@%40johndoe")).toBe(true);
|
|
9
|
+
});
|
|
10
|
+
});
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
export * from "./format-amount";
|
|
2
|
+
export * from "./format-date";
|
|
3
|
+
export * from "./format-plain-text";
|
|
4
|
+
export * from "./get-social-handle";
|
|
5
|
+
export * from "./get-user-page-link";
|
|
6
|
+
export * from "./open-to-new-window";
|
|
7
|
+
export * from "./post-to-facebook";
|
|
8
|
+
export * from "./post-to-instagram";
|
|
9
|
+
export * from "./post-to-linkedin";
|
|
10
|
+
export * from "./post-to-x";
|
|
11
|
+
export * from "./qr-svg-utils";
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { afterEach, describe, expect, test } from "bun:test";
|
|
2
|
+
import { openInNewWindow } from "./open-to-new-window";
|
|
3
|
+
|
|
4
|
+
describe("openInNewWindow", () => {
|
|
5
|
+
const originalWindow = (globalThis as any).window;
|
|
6
|
+
|
|
7
|
+
afterEach(() => {
|
|
8
|
+
(globalThis as any).window = originalWindow;
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
test("opens url in a new tab", () => {
|
|
12
|
+
const calls: unknown[][] = [];
|
|
13
|
+
(globalThis as any).window = {
|
|
14
|
+
open: (...args: unknown[]) => {
|
|
15
|
+
calls.push(args);
|
|
16
|
+
},
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
openInNewWindow("https://example.com");
|
|
20
|
+
|
|
21
|
+
expect(calls).toHaveLength(1);
|
|
22
|
+
expect(calls[0]).toEqual([
|
|
23
|
+
"https://example.com",
|
|
24
|
+
"_blank",
|
|
25
|
+
"noopener,noreferrer",
|
|
26
|
+
]);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
test("does nothing when window is undefined", () => {
|
|
30
|
+
(globalThis as any).window = undefined;
|
|
31
|
+
|
|
32
|
+
expect(() => openInNewWindow("https://example.com")).not.toThrow();
|
|
33
|
+
});
|
|
34
|
+
});
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { afterEach, describe, expect, test } from "bun:test";
|
|
2
|
+
import { shareToFacebook } from "./post-to-facebook";
|
|
3
|
+
|
|
4
|
+
describe("shareToFacebook", () => {
|
|
5
|
+
const originalWindow = (globalThis as any).window;
|
|
6
|
+
|
|
7
|
+
afterEach(() => {
|
|
8
|
+
(globalThis as any).window = originalWindow;
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
test("throws when url is missing", () => {
|
|
12
|
+
expect(() => shareToFacebook({ url: "" })).toThrow(
|
|
13
|
+
"Facebook share requires a URL",
|
|
14
|
+
);
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
test("opens facebook share url with params", () => {
|
|
18
|
+
const calls: unknown[][] = [];
|
|
19
|
+
(globalThis as any).window = {
|
|
20
|
+
open: (...args: unknown[]) => {
|
|
21
|
+
calls.push(args);
|
|
22
|
+
},
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
shareToFacebook({
|
|
26
|
+
url: "https://example.com",
|
|
27
|
+
quote: "Hello",
|
|
28
|
+
hashtag: "#coffee",
|
|
29
|
+
ref: "campaign",
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
const params = new URLSearchParams({ u: "https://example.com" });
|
|
33
|
+
params.append("quote", "Hello");
|
|
34
|
+
params.append("hashtag", "#coffee");
|
|
35
|
+
params.append("ref", "campaign");
|
|
36
|
+
|
|
37
|
+
expect(calls[0]).toEqual([
|
|
38
|
+
`https://www.facebook.com/sharer/sharer.php?${params.toString()}`,
|
|
39
|
+
"_blank",
|
|
40
|
+
"noopener,noreferrer",
|
|
41
|
+
]);
|
|
42
|
+
});
|
|
43
|
+
});
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
type FacebookShareOptions = {
|
|
2
|
+
url: string; // Required
|
|
3
|
+
quote?: string; // Optional (may be ignored by FB)
|
|
4
|
+
hashtag?: string; // Only ONE hashtag allowed
|
|
5
|
+
ref?: string; // Channel / campaign reference
|
|
6
|
+
};
|
|
7
|
+
|
|
8
|
+
export function shareToFacebook({
|
|
9
|
+
url,
|
|
10
|
+
quote,
|
|
11
|
+
hashtag,
|
|
12
|
+
ref,
|
|
13
|
+
}: FacebookShareOptions) {
|
|
14
|
+
if (!url) {
|
|
15
|
+
throw new Error("Facebook share requires a URL");
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const params = new URLSearchParams({
|
|
19
|
+
u: url,
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
if (quote) params.append("quote", quote);
|
|
23
|
+
if (hashtag) params.append("hashtag", hashtag);
|
|
24
|
+
if (ref) params.append("ref", ref);
|
|
25
|
+
|
|
26
|
+
const shareUrl = `https://www.facebook.com/sharer/sharer.php?${params.toString()}`;
|
|
27
|
+
|
|
28
|
+
window.open(shareUrl, "_blank", "noopener,noreferrer");
|
|
29
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test";
|
|
2
|
+
|
|
3
|
+
mock.module("sonner", () => ({
|
|
4
|
+
toast: {
|
|
5
|
+
success: () => undefined,
|
|
6
|
+
error: () => undefined,
|
|
7
|
+
},
|
|
8
|
+
}));
|
|
9
|
+
|
|
10
|
+
describe("shareToInstagram", () => {
|
|
11
|
+
const originalNavigator = (globalThis as any).navigator;
|
|
12
|
+
const originalWindow = (globalThis as any).window;
|
|
13
|
+
|
|
14
|
+
beforeEach(() => {
|
|
15
|
+
(globalThis as any).window = {
|
|
16
|
+
open: () => undefined,
|
|
17
|
+
};
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
afterEach(() => {
|
|
21
|
+
(globalThis as any).navigator = originalNavigator;
|
|
22
|
+
(globalThis as any).window = originalWindow;
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
test("throws when url is missing", async () => {
|
|
26
|
+
const { shareToInstagram } = await import("./post-to-instagram");
|
|
27
|
+
|
|
28
|
+
expect(() => shareToInstagram({ url: "" })).toThrow(
|
|
29
|
+
"Instagram share requires a URL",
|
|
30
|
+
);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
test("uses Web Share API when available", async () => {
|
|
34
|
+
const shareCalls: unknown[] = [];
|
|
35
|
+
|
|
36
|
+
(globalThis as any).navigator = {
|
|
37
|
+
share: (payload: unknown) => {
|
|
38
|
+
shareCalls.push(payload);
|
|
39
|
+
return Promise.resolve();
|
|
40
|
+
},
|
|
41
|
+
clipboard: {
|
|
42
|
+
writeText: () => Promise.resolve(),
|
|
43
|
+
},
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
const { shareToInstagram } = await import("./post-to-instagram");
|
|
47
|
+
|
|
48
|
+
shareToInstagram({ url: "https://example.com", text: "Look" });
|
|
49
|
+
|
|
50
|
+
expect(shareCalls).toHaveLength(1);
|
|
51
|
+
expect(shareCalls[0]).toEqual({
|
|
52
|
+
title: "Look",
|
|
53
|
+
url: "https://example.com",
|
|
54
|
+
});
|
|
55
|
+
});
|
|
56
|
+
});
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { toast } from "sonner";
|
|
2
|
+
|
|
3
|
+
type InstagramShareOptions = {
|
|
4
|
+
url: string; // Required
|
|
5
|
+
text?: string; // Optional text to include
|
|
6
|
+
};
|
|
7
|
+
|
|
8
|
+
export function shareToInstagram({ url, text }: InstagramShareOptions) {
|
|
9
|
+
if (!url) {
|
|
10
|
+
throw new Error("Instagram share requires a URL");
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
// Instagram doesn't have a direct web share URL like Facebook or LinkedIn
|
|
14
|
+
// We'll use the Web Share API if available, otherwise fall back to copying the link
|
|
15
|
+
if (navigator.share) {
|
|
16
|
+
navigator
|
|
17
|
+
.share({
|
|
18
|
+
title: text || "Check this out!",
|
|
19
|
+
url,
|
|
20
|
+
})
|
|
21
|
+
.catch(() => {
|
|
22
|
+
toast.error("Failed to share to Instagram.");
|
|
23
|
+
});
|
|
24
|
+
} else {
|
|
25
|
+
// Fallback: Open Instagram website or copy to clipboard
|
|
26
|
+
// Since Instagram doesn't support direct web sharing, we'll open their homepage
|
|
27
|
+
// Users can manually paste the link in their post
|
|
28
|
+
window.open("https://www.instagram.com/", "_blank", "noopener,noreferrer");
|
|
29
|
+
|
|
30
|
+
// Copy the URL to clipboard for easy pasting
|
|
31
|
+
navigator.clipboard
|
|
32
|
+
.writeText(url)
|
|
33
|
+
.then(() => {
|
|
34
|
+
toast.success(
|
|
35
|
+
"Link copied to clipboard. You can now paste it in your Instagram post."
|
|
36
|
+
);
|
|
37
|
+
})
|
|
38
|
+
.catch(() => {
|
|
39
|
+
toast.error("Failed to copy link to clipboard.");
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { afterEach, describe, expect, test } from "bun:test";
|
|
2
|
+
import { shareToLinkedIn } from "./post-to-linkedin";
|
|
3
|
+
|
|
4
|
+
describe("shareToLinkedIn", () => {
|
|
5
|
+
const originalWindow = (globalThis as any).window;
|
|
6
|
+
|
|
7
|
+
afterEach(() => {
|
|
8
|
+
(globalThis as any).window = originalWindow;
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
test("throws when url is missing", () => {
|
|
12
|
+
expect(() => shareToLinkedIn({ url: "" })).toThrow(
|
|
13
|
+
"LinkedIn share requires a URL",
|
|
14
|
+
);
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
test("opens linkedin share url with params", () => {
|
|
18
|
+
const calls: unknown[][] = [];
|
|
19
|
+
(globalThis as any).window = {
|
|
20
|
+
open: (...args: unknown[]) => {
|
|
21
|
+
calls.push(args);
|
|
22
|
+
},
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
shareToLinkedIn({
|
|
26
|
+
url: "https://example.com",
|
|
27
|
+
title: "Title",
|
|
28
|
+
summary: "Summary",
|
|
29
|
+
source: "Source",
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
const params = new URLSearchParams({ url: "https://example.com" });
|
|
33
|
+
params.append("title", "Title");
|
|
34
|
+
params.append("summary", "Summary");
|
|
35
|
+
params.append("source", "Source");
|
|
36
|
+
|
|
37
|
+
expect(calls[0]).toEqual([
|
|
38
|
+
`https://www.linkedin.com/sharing/share-offsite/?${params.toString()}`,
|
|
39
|
+
"_blank",
|
|
40
|
+
"noopener,noreferrer",
|
|
41
|
+
]);
|
|
42
|
+
});
|
|
43
|
+
});
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
type LinkedInShareOptions = {
|
|
2
|
+
url: string; // Required
|
|
3
|
+
title?: string; // Optional title
|
|
4
|
+
summary?: string; // Optional summary/description
|
|
5
|
+
source?: string; // Optional source attribution
|
|
6
|
+
};
|
|
7
|
+
|
|
8
|
+
export function shareToLinkedIn({
|
|
9
|
+
url,
|
|
10
|
+
title,
|
|
11
|
+
summary,
|
|
12
|
+
source,
|
|
13
|
+
}: LinkedInShareOptions) {
|
|
14
|
+
if (!url) {
|
|
15
|
+
throw new Error("LinkedIn share requires a URL");
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const params = new URLSearchParams({
|
|
19
|
+
url,
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
if (title) params.append("title", title);
|
|
23
|
+
if (summary) params.append("summary", summary);
|
|
24
|
+
if (source) params.append("source", source);
|
|
25
|
+
|
|
26
|
+
const shareUrl = `https://www.linkedin.com/sharing/share-offsite/?${params.toString()}`;
|
|
27
|
+
|
|
28
|
+
window.open(shareUrl, "_blank", "noopener,noreferrer");
|
|
29
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { afterEach, describe, expect, test } from "bun:test";
|
|
2
|
+
import { shareToX } from "./post-to-x";
|
|
3
|
+
|
|
4
|
+
describe("shareToX", () => {
|
|
5
|
+
const originalWindow = (globalThis as any).window;
|
|
6
|
+
|
|
7
|
+
afterEach(() => {
|
|
8
|
+
(globalThis as any).window = originalWindow;
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
test("throws when text is missing", () => {
|
|
12
|
+
expect(() => shareToX({ text: "" })).toThrow(
|
|
13
|
+
"X share requires text content",
|
|
14
|
+
);
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
test("opens x intent url with params", () => {
|
|
18
|
+
const calls: unknown[][] = [];
|
|
19
|
+
(globalThis as any).window = {
|
|
20
|
+
open: (...args: unknown[]) => {
|
|
21
|
+
calls.push(args);
|
|
22
|
+
},
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
shareToX({
|
|
26
|
+
text: "Hello",
|
|
27
|
+
url: "https://example.com",
|
|
28
|
+
hashtags: "coffee,shop",
|
|
29
|
+
via: "hobenaki",
|
|
30
|
+
related: "friend1,friend2",
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
const params = new URLSearchParams({ text: "Hello" });
|
|
34
|
+
params.append("url", "https://example.com");
|
|
35
|
+
params.append("hashtags", "coffee,shop");
|
|
36
|
+
params.append("via", "hobenaki");
|
|
37
|
+
params.append("related", "friend1,friend2");
|
|
38
|
+
|
|
39
|
+
expect(calls[0]).toEqual([
|
|
40
|
+
`https://twitter.com/intent/tweet?${params.toString()}`,
|
|
41
|
+
"_blank",
|
|
42
|
+
"noopener,noreferrer",
|
|
43
|
+
]);
|
|
44
|
+
});
|
|
45
|
+
});
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
type XShareOptions = {
|
|
2
|
+
text: string; // Required
|
|
3
|
+
url?: string; // Optional URL to share
|
|
4
|
+
hashtags?: string; // Comma-separated hashtags (without #)
|
|
5
|
+
via?: string; // Twitter username to attribute (without @)
|
|
6
|
+
related?: string; // Comma-separated accounts to recommend
|
|
7
|
+
};
|
|
8
|
+
|
|
9
|
+
export function shareToX({ text, url, hashtags, via, related }: XShareOptions) {
|
|
10
|
+
if (!text) {
|
|
11
|
+
throw new Error("X share requires text content");
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const params = new URLSearchParams({
|
|
15
|
+
text,
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
if (url) params.append("url", url);
|
|
19
|
+
if (hashtags) params.append("hashtags", hashtags);
|
|
20
|
+
if (via) params.append("via", via);
|
|
21
|
+
if (related) params.append("related", related);
|
|
22
|
+
|
|
23
|
+
const shareUrl = `https://twitter.com/intent/tweet?${params.toString()}`;
|
|
24
|
+
|
|
25
|
+
window.open(shareUrl, "_blank", "noopener,noreferrer");
|
|
26
|
+
}
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import { afterEach, describe, expect, mock, test } from "bun:test";
|
|
2
|
+
|
|
3
|
+
mock.module("sonner", () => ({
|
|
4
|
+
toast: {
|
|
5
|
+
success: () => undefined,
|
|
6
|
+
error: () => undefined,
|
|
7
|
+
},
|
|
8
|
+
}));
|
|
9
|
+
|
|
10
|
+
describe("downloadQrSvgAsPng", () => {
|
|
11
|
+
const originalImage = (globalThis as any).Image;
|
|
12
|
+
|
|
13
|
+
afterEach(() => {
|
|
14
|
+
(globalThis as any).Image = originalImage;
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
test("calls onError when image loading fails", async () => {
|
|
18
|
+
class BrokenImage {
|
|
19
|
+
public onload: (() => void) | null = null;
|
|
20
|
+
public onerror: (() => void) | null = null;
|
|
21
|
+
public decoding = "async";
|
|
22
|
+
|
|
23
|
+
set src(_value: string) {
|
|
24
|
+
this.onerror?.();
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
(globalThis as any).Image = BrokenImage;
|
|
29
|
+
|
|
30
|
+
const { downloadQrSvgAsPng } = await import("./qr-svg-utils");
|
|
31
|
+
|
|
32
|
+
let failed = false;
|
|
33
|
+
await downloadQrSvgAsPng("<svg></svg>", "test.png", undefined, () => {
|
|
34
|
+
failed = true;
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
expect(failed).toBe(true);
|
|
38
|
+
});
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
describe("printQrSvg", () => {
|
|
42
|
+
const originalDocument = (globalThis as any).document;
|
|
43
|
+
const originalDOMParser = (globalThis as any).DOMParser;
|
|
44
|
+
|
|
45
|
+
afterEach(() => {
|
|
46
|
+
(globalThis as any).document = originalDocument;
|
|
47
|
+
(globalThis as any).DOMParser = originalDOMParser;
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
test("calls onError for invalid SVG markup", async () => {
|
|
51
|
+
const createNode = () => ({
|
|
52
|
+
style: {},
|
|
53
|
+
textContent: "",
|
|
54
|
+
setAttribute: () => undefined,
|
|
55
|
+
append: () => undefined,
|
|
56
|
+
appendChild: () => undefined,
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
const iframeDocument = {
|
|
60
|
+
documentElement: {},
|
|
61
|
+
createElement: () => createNode(),
|
|
62
|
+
replaceChild: () => undefined,
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
const iframe = {
|
|
66
|
+
style: {},
|
|
67
|
+
contentWindow: {
|
|
68
|
+
document: iframeDocument,
|
|
69
|
+
addEventListener: () => undefined,
|
|
70
|
+
focus: () => undefined,
|
|
71
|
+
print: () => undefined,
|
|
72
|
+
},
|
|
73
|
+
setAttribute: () => undefined,
|
|
74
|
+
remove: () => undefined,
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
(globalThis as any).document = {
|
|
78
|
+
createElement: (tagName: string) =>
|
|
79
|
+
tagName === "iframe" ? iframe : createNode(),
|
|
80
|
+
body: {
|
|
81
|
+
append: () => undefined,
|
|
82
|
+
},
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
(globalThis as any).DOMParser = class {
|
|
86
|
+
parseFromString() {
|
|
87
|
+
return {
|
|
88
|
+
documentElement: {
|
|
89
|
+
nodeName: "parsererror",
|
|
90
|
+
},
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
const { printQrSvg } = await import("./qr-svg-utils");
|
|
96
|
+
|
|
97
|
+
let failed = false;
|
|
98
|
+
printQrSvg("<svg", "QR Print", () => {
|
|
99
|
+
failed = true;
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
expect(failed).toBe(true);
|
|
103
|
+
});
|
|
104
|
+
});
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
// Utility functions for QR code SVG download and print
|
|
2
|
+
|
|
3
|
+
import { toast } from "sonner";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Downloads a QR code SVG as a PNG file.
|
|
7
|
+
* @param svgMarkup - The SVG markup string
|
|
8
|
+
* @param fileName - The file name for the PNG
|
|
9
|
+
* @param onSuccess - Callback for success
|
|
10
|
+
* @param onError - Callback for error
|
|
11
|
+
*/
|
|
12
|
+
export async function downloadQrSvgAsPng(
|
|
13
|
+
svgMarkup: string,
|
|
14
|
+
fileName: string,
|
|
15
|
+
onSuccess?: () => void,
|
|
16
|
+
onError?: () => void
|
|
17
|
+
): Promise<void> {
|
|
18
|
+
try {
|
|
19
|
+
const svgBlob = new Blob([svgMarkup], { type: "image/svg+xml" });
|
|
20
|
+
const svgUrl = URL.createObjectURL(svgBlob);
|
|
21
|
+
|
|
22
|
+
const image = new Image();
|
|
23
|
+
image.decoding = "async";
|
|
24
|
+
|
|
25
|
+
await new Promise<void>((resolve, reject) => {
|
|
26
|
+
image.onload = () => resolve();
|
|
27
|
+
image.onerror = () => reject(new Error("QR image failed to load"));
|
|
28
|
+
image.src = svgUrl;
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
const canvas = document.createElement("canvas");
|
|
32
|
+
const context = canvas.getContext("2d");
|
|
33
|
+
if (!context) {
|
|
34
|
+
URL.revokeObjectURL(svgUrl);
|
|
35
|
+
throw new Error("Canvas context unavailable");
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const exportScale = 8;
|
|
39
|
+
const maxSize = 2400;
|
|
40
|
+
const scaleFromMaxSize = maxSize / image.width;
|
|
41
|
+
const scale = Math.min(exportScale, scaleFromMaxSize);
|
|
42
|
+
|
|
43
|
+
canvas.width = Math.round(image.width * scale);
|
|
44
|
+
canvas.height = Math.round(image.height * scale);
|
|
45
|
+
|
|
46
|
+
context.fillStyle = "white";
|
|
47
|
+
context.fillRect(0, 0, canvas.width, canvas.height);
|
|
48
|
+
context.imageSmoothingEnabled = false;
|
|
49
|
+
context.drawImage(image, 0, 0, canvas.width, canvas.height);
|
|
50
|
+
|
|
51
|
+
URL.revokeObjectURL(svgUrl);
|
|
52
|
+
|
|
53
|
+
const pngBlob = await new Promise<Blob>((resolve, reject) => {
|
|
54
|
+
canvas.toBlob((blob) => {
|
|
55
|
+
if (!blob) {
|
|
56
|
+
reject(new Error("PNG blob generation failed"));
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
resolve(blob);
|
|
60
|
+
}, "image/png");
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
const pngUrl = URL.createObjectURL(pngBlob);
|
|
64
|
+
const link = document.createElement("a");
|
|
65
|
+
link.href = pngUrl;
|
|
66
|
+
link.download = fileName;
|
|
67
|
+
link.click();
|
|
68
|
+
|
|
69
|
+
setTimeout(() => {
|
|
70
|
+
URL.revokeObjectURL(pngUrl);
|
|
71
|
+
}, 1000);
|
|
72
|
+
|
|
73
|
+
onSuccess?.();
|
|
74
|
+
} catch {
|
|
75
|
+
toast.error("Download failed");
|
|
76
|
+
onError?.();
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Prints a QR code SVG markup.
|
|
82
|
+
* @param svgMarkup - The SVG markup string
|
|
83
|
+
* @param printTitle - The title for the print window
|
|
84
|
+
* @param onError - Callback for error
|
|
85
|
+
*/
|
|
86
|
+
export function printQrSvg(
|
|
87
|
+
svgMarkup: string,
|
|
88
|
+
printTitle: string,
|
|
89
|
+
onError?: () => void
|
|
90
|
+
): void {
|
|
91
|
+
const iframe = document.createElement("iframe");
|
|
92
|
+
iframe.setAttribute("title", printTitle);
|
|
93
|
+
iframe.style.position = "fixed";
|
|
94
|
+
iframe.style.right = "0";
|
|
95
|
+
iframe.style.bottom = "0";
|
|
96
|
+
iframe.style.width = "0";
|
|
97
|
+
iframe.style.height = "0";
|
|
98
|
+
iframe.style.border = "0";
|
|
99
|
+
iframe.style.opacity = "0";
|
|
100
|
+
iframe.style.pointerEvents = "none";
|
|
101
|
+
|
|
102
|
+
document.body.append(iframe);
|
|
103
|
+
|
|
104
|
+
const cleanup = (): void => {
|
|
105
|
+
iframe.remove();
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
const iframeWindow = iframe.contentWindow;
|
|
109
|
+
const iframeDocument = iframeWindow?.document;
|
|
110
|
+
|
|
111
|
+
if (!(iframeWindow && iframeDocument)) {
|
|
112
|
+
cleanup();
|
|
113
|
+
onError?.();
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
iframeWindow.addEventListener(
|
|
118
|
+
"afterprint",
|
|
119
|
+
() => {
|
|
120
|
+
cleanup();
|
|
121
|
+
},
|
|
122
|
+
{ once: true }
|
|
123
|
+
);
|
|
124
|
+
|
|
125
|
+
// Construct document structure using DOM methods
|
|
126
|
+
const html = iframeDocument.createElement("html");
|
|
127
|
+
const head = iframeDocument.createElement("head");
|
|
128
|
+
const body = iframeDocument.createElement("body");
|
|
129
|
+
|
|
130
|
+
const meta = iframeDocument.createElement("meta");
|
|
131
|
+
meta.setAttribute("charset", "utf-8");
|
|
132
|
+
|
|
133
|
+
const title = iframeDocument.createElement("title");
|
|
134
|
+
title.textContent = printTitle;
|
|
135
|
+
|
|
136
|
+
const style = iframeDocument.createElement("style");
|
|
137
|
+
style.textContent = `
|
|
138
|
+
@page { margin: 0; }
|
|
139
|
+
html, body { height: 100%; }
|
|
140
|
+
body {
|
|
141
|
+
margin: 0;
|
|
142
|
+
display: flex;
|
|
143
|
+
align-items: center;
|
|
144
|
+
justify-content: center;
|
|
145
|
+
background: #fff;
|
|
146
|
+
}
|
|
147
|
+
svg { width: 70vmin; height: 70vmin; }
|
|
148
|
+
`;
|
|
149
|
+
|
|
150
|
+
head.append(meta, title, style);
|
|
151
|
+
html.append(head, body);
|
|
152
|
+
|
|
153
|
+
// Replace the existing document element
|
|
154
|
+
iframeDocument.replaceChild(html, iframeDocument.documentElement);
|
|
155
|
+
|
|
156
|
+
// Safely parse and insert SVG
|
|
157
|
+
try {
|
|
158
|
+
const parser = new DOMParser();
|
|
159
|
+
const svgDoc = parser.parseFromString(svgMarkup, "image/svg+xml");
|
|
160
|
+
const svgElement = svgDoc.documentElement;
|
|
161
|
+
|
|
162
|
+
if (svgElement.nodeName === "parsererror") {
|
|
163
|
+
throw new Error("Invalid SVG markup");
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
body.appendChild(svgElement);
|
|
167
|
+
} catch {
|
|
168
|
+
toast.error("Failed to load QR code for printing");
|
|
169
|
+
cleanup();
|
|
170
|
+
onError?.();
|
|
171
|
+
return;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
try {
|
|
175
|
+
iframeWindow.focus();
|
|
176
|
+
iframeWindow.print();
|
|
177
|
+
} catch {
|
|
178
|
+
toast.error("Printing failed");
|
|
179
|
+
cleanup();
|
|
180
|
+
onError?.();
|
|
181
|
+
}
|
|
182
|
+
}
|