Skip to content

Commit ddbc625

Browse files
committed
feat(blog): add ShareModal component
1 parent a64baeb commit ddbc625

File tree

2 files changed

+315
-0
lines changed

2 files changed

+315
-0
lines changed
Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,136 @@
1+
/**
2+
* MIT License
3+
*
4+
* Copyright (c) 2025 Josimar Silva
5+
*
6+
* Permission is hereby granted, free of charge, to any person obtaining a copy
7+
* of this software and associated documentation files (the "Software"), to deal
8+
* in the Software without restriction, including without limitation the rights
9+
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
10+
* copies of the Software, and to permit persons to whom the Software is
11+
* furnished to do so, subject to the following conditions:
12+
*
13+
* The above copyright notice and this permission notice shall be included in all
14+
* copies or substantial portions of the Software.
15+
*
16+
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
17+
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
18+
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
19+
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
20+
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
21+
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
22+
* SOFTWARE.
23+
*/
24+
25+
import "@testing-library/jest-dom";
26+
27+
import { render, screen } from "@testing-library/react";
28+
import userEvent from "@testing-library/user-event";
29+
30+
import ShareModal from "./share-modal";
31+
32+
describe("ShareModal", () => {
33+
let writeTextSpy: jest.SpyInstance;
34+
35+
beforeAll(() => {
36+
Object.defineProperty(navigator, "clipboard", {
37+
value: {
38+
writeText: jest.fn(),
39+
},
40+
writable: true,
41+
configurable: true, // Added configurable to allow redefinition if needed by userEvent
42+
});
43+
});
44+
45+
beforeEach(() => {
46+
writeTextSpy = jest.spyOn(navigator.clipboard, "writeText");
47+
});
48+
49+
afterEach(() => {
50+
writeTextSpy.mockRestore();
51+
jest.clearAllMocks();
52+
});
53+
54+
it("renders a share button", () => {
55+
render(<ShareModal title="Test Post" slug="test-post" />);
56+
expect(screen.getByRole("button", { name: /share/i })).toBeInTheDocument();
57+
});
58+
59+
it("opens and closes the modal", async () => {
60+
const user = userEvent.setup();
61+
render(<ShareModal title="Test Post" slug="test-post" />);
62+
63+
const shareButton = screen.getByRole("button", { name: /share/i });
64+
await user.click(shareButton);
65+
66+
const dialog = screen.getByRole("dialog");
67+
expect(dialog).toBeInTheDocument();
68+
69+
const closeButton = screen.getByRole("button", { name: /close/i });
70+
await user.click(closeButton);
71+
72+
expect(dialog).not.toBeInTheDocument();
73+
});
74+
75+
it("copies the post link to clipboard", async () => {
76+
const user = userEvent.setup();
77+
const title = "Test Post";
78+
const slug = "test-post";
79+
render(<ShareModal title={title} slug={slug} />);
80+
81+
const shareButton = screen.getByRole("button", { name: /share/i });
82+
await user.click(shareButton);
83+
84+
const copyLinkButton = screen.getByRole("button", { name: /copy link/i });
85+
await user.click(copyLinkButton);
86+
87+
expect(writeTextSpy).toHaveBeenCalledWith(
88+
`http://localhost:3000/blog/${slug}`,
89+
);
90+
});
91+
92+
it("renders Twitter share button", async () => {
93+
const user = userEvent.setup();
94+
render(<ShareModal title="Test Post" slug="test-post" />);
95+
await user.click(screen.getByRole("button", { name: /share/i }));
96+
expect(
97+
screen.getByRole("button", { name: /share on twitter/i }),
98+
).toBeInTheDocument();
99+
});
100+
101+
it("renders LinkedIn share button", async () => {
102+
const user = userEvent.setup();
103+
render(<ShareModal title="Test Post" slug="test-post" />);
104+
await user.click(screen.getByRole("button", { name: /share/i }));
105+
expect(
106+
screen.getByRole("button", { name: /share on linkedin/i }),
107+
).toBeInTheDocument();
108+
});
109+
110+
it("renders WhatsApp share button", async () => {
111+
const user = userEvent.setup();
112+
render(<ShareModal title="Test Post" slug="test-post" />);
113+
await user.click(screen.getByRole("button", { name: /share/i }));
114+
expect(
115+
screen.getByRole("button", { name: /share on whatsapp/i }),
116+
).toBeInTheDocument();
117+
});
118+
119+
it("renders Telegram share button", async () => {
120+
const user = userEvent.setup();
121+
render(<ShareModal title="Test Post" slug="test-post" />);
122+
await user.click(screen.getByRole("button", { name: /share/i }));
123+
expect(
124+
screen.getByRole("button", { name: /share on telegram/i }),
125+
).toBeInTheDocument();
126+
});
127+
128+
it("renders Email share button", async () => {
129+
const user = userEvent.setup();
130+
render(<ShareModal title="Test Post" slug="test-post" />);
131+
await user.click(screen.getByRole("button", { name: /share/i }));
132+
expect(
133+
screen.getByRole("button", { name: /share via email/i }),
134+
).toBeInTheDocument();
135+
});
136+
});
Lines changed: 179 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,179 @@
1+
/**
2+
* MIT License
3+
*
4+
* Copyright (c) 2025 Josimar Silva
5+
*
6+
* Permission is hereby granted, free of charge, to any person obtaining a copy
7+
* of this software and associated documentation files (the "Software"), to deal
8+
* in the Software without restriction, including without limitation the rights
9+
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
10+
* copies of the Software, and to permit persons to whom the Software is
11+
* furnished to do so, subject to the following conditions:
12+
*
13+
* The above copyright notice and this permission notice shall be included in all
14+
* copies or substantial portions of the Software.
15+
*
16+
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
17+
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
18+
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
19+
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
20+
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
21+
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
22+
* SOFTWARE.
23+
*/
24+
25+
"use client";
26+
27+
import {
28+
Check,
29+
Copy,
30+
Linkedin,
31+
Mail,
32+
MessageCircle,
33+
Send,
34+
Share2,
35+
Twitter,
36+
} from "lucide-react";
37+
import { useState } from "react";
38+
import {
39+
EmailShareButton,
40+
LinkedinShareButton,
41+
TelegramShareButton,
42+
TwitterShareButton,
43+
WhatsappShareButton,
44+
} from "react-share";
45+
46+
import { Button } from "@/app/__components/ui/button";
47+
import {
48+
Dialog,
49+
DialogClose,
50+
DialogContent,
51+
DialogDescription,
52+
DialogHeader,
53+
DialogTitle,
54+
DialogTrigger,
55+
} from "@/app/__components/ui/dialog";
56+
import { Input } from "@/app/__components/ui/input";
57+
import { Label } from "@/app/__components/ui/label";
58+
import { Separator } from "@/app/__components/ui/separator";
59+
import { useToast } from "@/app/__components/ui/use-toast";
60+
61+
interface ShareModalProps {
62+
title: string;
63+
slug: string;
64+
}
65+
66+
const siteUrl = process.env.NEXT_PUBLIC_SITE_URL || "http://localhost:3000";
67+
68+
const ShareModal = ({ title, slug }: ShareModalProps) => {
69+
const [open, setOpen] = useState(false);
70+
const [copied, setCopied] = useState(false);
71+
const { toast } = useToast();
72+
const shareUrl = `${siteUrl}/blog/${slug}`;
73+
74+
const handleCopyLink = () => {
75+
navigator.clipboard
76+
.writeText(shareUrl)
77+
.then(() => {
78+
setCopied(true);
79+
toast({
80+
title: "Link copied!",
81+
description: "The post link has been copied to your clipboard.",
82+
});
83+
})
84+
.catch(() => {
85+
toast({
86+
title: "Copy failed",
87+
description:
88+
"Could not copy link. Please copy manually from the input field.",
89+
variant: "destructive",
90+
});
91+
});
92+
};
93+
94+
return (
95+
<Dialog open={open} onOpenChange={setOpen}>
96+
<DialogTrigger asChild>
97+
<Button variant="outline" size="sm">
98+
<Share2 className="mr-2 h-4 w-4" />
99+
Share
100+
</Button>
101+
</DialogTrigger>
102+
<DialogContent className="sm:max-w-md">
103+
<DialogHeader>
104+
<DialogTitle>Share Post</DialogTitle>
105+
<DialogDescription>
106+
Share this post with your friends.
107+
</DialogDescription>
108+
</DialogHeader>
109+
<div className="flex items-center space-x-2">
110+
<div className="grid flex-1 gap-2">
111+
<Label htmlFor="link" className="sr-only">
112+
Link
113+
</Label>
114+
<Input id="link" defaultValue={shareUrl} readOnly />
115+
</div>
116+
<Button
117+
type="submit"
118+
size="sm"
119+
className="px-3"
120+
onClick={handleCopyLink}
121+
>
122+
<span className="sr-only">Copy Link</span>
123+
{copied ? (
124+
<Check className="h-4 w-4" />
125+
) : (
126+
<Copy className="h-4 w-4" />
127+
)}
128+
</Button>
129+
</div>
130+
<Separator />
131+
<div className="flex justify-center gap-4 py-2">
132+
<TwitterShareButton
133+
url={shareUrl}
134+
title={title}
135+
className="flex items-center justify-center rounded-md border border-input bg-background hover:bg-accent hover:text-accent-foreground h-9 w-9"
136+
aria-label="Share on Twitter"
137+
>
138+
<Twitter className="h-5 w-5" />
139+
</TwitterShareButton>
140+
<LinkedinShareButton
141+
url={shareUrl}
142+
title={title}
143+
className="flex items-center justify-center rounded-md border border-input bg-background hover:bg-accent hover:text-accent-foreground h-9 w-9"
144+
aria-label="Share on LinkedIn"
145+
>
146+
<Linkedin className="h-5 w-5" />
147+
</LinkedinShareButton>
148+
<WhatsappShareButton
149+
url={shareUrl}
150+
title={title}
151+
className="flex items-center justify-center rounded-md border border-input bg-background hover:bg-accent hover:text-accent-foreground h-9 w-9"
152+
aria-label="Share on WhatsApp"
153+
>
154+
<MessageCircle className="h-5 w-5" />
155+
</WhatsappShareButton>
156+
<TelegramShareButton
157+
url={shareUrl}
158+
title={title}
159+
className="flex items-center justify-center rounded-md border border-input bg-background hover:bg-accent hover:text-accent-foreground h-9 w-9"
160+
aria-label="Share on Telegram"
161+
>
162+
<Send className="h-5 w-5" />
163+
</TelegramShareButton>
164+
<EmailShareButton
165+
url={shareUrl}
166+
subject={title}
167+
className="flex items-center justify-center rounded-md border border-input bg-background hover:bg-accent hover:text-accent-foreground h-9 w-9"
168+
aria-label="Share via Email"
169+
>
170+
<Mail className="h-5 w-5" />
171+
</EmailShareButton>
172+
</div>
173+
<DialogClose />
174+
</DialogContent>
175+
</Dialog>
176+
);
177+
};
178+
179+
export default ShareModal;

0 commit comments

Comments
 (0)