diff --git a/src/app/[lang]/profile/ProfileRouteClient.tsx b/src/app/[lang]/profile/ProfileRouteClient.tsx
index 2bfb2dc..7d22f0b 100644
--- a/src/app/[lang]/profile/ProfileRouteClient.tsx
+++ b/src/app/[lang]/profile/ProfileRouteClient.tsx
@@ -8,6 +8,7 @@ import type { ProfileFormValues } from '@/features/Profile/schema';
type ProfileRouteClientProps = {
initialValues?: ProfileFormValues;
locale: string;
+ profileId?: string;
userAvatarUrl?: string;
userDisplayName: string;
};
@@ -15,6 +16,7 @@ type ProfileRouteClientProps = {
export const ProfileRouteClient = ({
initialValues,
locale,
+ profileId,
userAvatarUrl,
userDisplayName,
}: ProfileRouteClientProps) => {
@@ -41,6 +43,9 @@ export const ProfileRouteClient = ({
initialValues={initialValues}
userAvatarUrl={userAvatarUrl}
userDisplayName={userDisplayName}
+ onViewPublicProfile={
+ profileId ? () => router.push(`/${locale}/u/${profileId}`) : undefined
+ }
onSubmit={async (data) => {
const response = await fetch('/api/profile', {
method: 'POST',
diff --git a/src/app/[lang]/profile/page.tsx b/src/app/[lang]/profile/page.tsx
index 5032bae..85e00e3 100644
--- a/src/app/[lang]/profile/page.tsx
+++ b/src/app/[lang]/profile/page.tsx
@@ -47,6 +47,7 @@ export default async function ProfileRoute({
diff --git a/src/app/[lang]/u/[id]/PublicProfileClient.tsx b/src/app/[lang]/u/[id]/PublicProfileClient.tsx
new file mode 100644
index 0000000..49c665f
--- /dev/null
+++ b/src/app/[lang]/u/[id]/PublicProfileClient.tsx
@@ -0,0 +1,151 @@
+'use client';
+
+import { useTranslations } from 'next-intl';
+import { useState } from 'react';
+import { MdArrowBack } from 'react-icons/md';
+import { ToastStack } from '@/components/Toast';
+import { buildDiscoveryFilterHref } from '@/features/Discovery/discoveryUrlState';
+import {
+ type DiscoveryProfile,
+ ProfileCard,
+} from '@/features/Discovery/ProfileCard';
+import { saveProfileRequest } from '@/features/Discovery/saveProfileRequest';
+import {
+ buildPublicProfileUrl,
+ shareProfileUrl,
+} from '@/features/Discovery/shareProfile';
+import { Navbar } from '@/features/Navbar';
+import { useRouteProgressRouter } from '@/features/Navigation/RouteProgress';
+import { useToastStack } from '@/hooks/useToast';
+
+type PublicProfileClientProps = {
+ locale: string;
+ profile: DiscoveryProfile;
+ isLoggedIn: boolean;
+ isSaved: boolean;
+ currentProfileId?: string;
+ viewerTimezone?: string;
+ userAvatarUrl?: string;
+};
+
+const TOAST_DURATION = 4000;
+
+export const PublicProfileClient = ({
+ locale,
+ profile,
+ isLoggedIn,
+ isSaved: initialSaved,
+ currentProfileId,
+ viewerTimezone,
+ userAvatarUrl,
+}: PublicProfileClientProps) => {
+ const router = useRouteProgressRouter();
+ const t = useTranslations('Discovery');
+ const tPublic = useTranslations('PublicProfile');
+ const { toasts, addToast, dismissToast } = useToastStack();
+ const [isSaved, setIsSaved] = useState(initialSaved);
+
+ const canSave = isLoggedIn && profile.id !== currentProfileId;
+
+ const handleToggleSave = async () => {
+ if (!isLoggedIn) {
+ addToast({
+ title: t('saveLoginTitle'),
+ description: t('saveLoginDescription'),
+ duration: TOAST_DURATION,
+ });
+ return;
+ }
+
+ const nextSaved = !isSaved;
+ setIsSaved(nextSaved);
+
+ try {
+ await saveProfileRequest(profile.id, nextSaved);
+ } catch {
+ setIsSaved(!nextSaved);
+ addToast({
+ title: t('saveError'),
+ description: t('saveErrorDescription'),
+ duration: TOAST_DURATION,
+ });
+ }
+ };
+
+ const handleShare = async () => {
+ const result = await shareProfileUrl(
+ buildPublicProfileUrl(locale, profile.id),
+ );
+
+ if (result === 'copied') {
+ addToast({
+ title: t('shareCopiedTitle'),
+ description: t('shareCopiedDescription'),
+ duration: TOAST_DURATION,
+ });
+ } else if (result === 'error') {
+ addToast({
+ title: t('shareErrorTitle'),
+ description: t('shareErrorDescription'),
+ duration: TOAST_DURATION,
+ });
+ }
+ };
+
+ return (
+
+
router.push(`/${locale}`)}
+ onLoginClick={() =>
+ window.location.assign(`/api/auth/discord?locale=${locale}`)
+ }
+ onProfileClick={() => router.push(`/${locale}/profile`)}
+ onSavedClick={() => router.push(`/${locale}/saved`)}
+ onSettingsClick={() => router.push(`/${locale}/settings`)}
+ onLogoutClick={() =>
+ window.location.assign(`/api/auth/logout?locale=${locale}`)
+ }
+ />
+
+
+
+
+
+ router.push(buildDiscoveryFilterHref(locale, 'tag', tag))
+ }
+ onLanguageClick={(language, _level, isPrimary) =>
+ router.push(
+ buildDiscoveryFilterHref(
+ locale,
+ isPrimary ? 'primaryLanguage' : 'targetLanguage',
+ language,
+ ),
+ )
+ }
+ onCountryClick={(country) =>
+ router.push(buildDiscoveryFilterHref(locale, 'country', country))
+ }
+ />
+
+
+
+
+ );
+};
diff --git a/src/app/[lang]/u/[id]/page.tsx b/src/app/[lang]/u/[id]/page.tsx
new file mode 100644
index 0000000..c2c2335
--- /dev/null
+++ b/src/app/[lang]/u/[id]/page.tsx
@@ -0,0 +1,58 @@
+import { notFound } from 'next/navigation';
+import {
+ getProfileByUserId,
+ getPublicProfileById,
+ listSavedProfileIds,
+ mapProfileToDiscoveryProfile,
+ toViewerAvailabilityContext,
+ upsertDiscordUser,
+} from '@/db';
+import { getCurrentUser } from '@/lib/auth';
+import { PublicProfileClient } from './PublicProfileClient';
+
+export default async function PublicProfileRoute({
+ params,
+}: {
+ params: Promise<{ lang: string; id: string }>;
+}) {
+ const { lang, id } = await params;
+ const row = await getPublicProfileById(id);
+
+ if (!row) {
+ notFound();
+ }
+
+ const profile = mapProfileToDiscoveryProfile(row);
+ const user = await getCurrentUser();
+
+ let isLoggedIn = false;
+ let savedProfileIds: string[] = [];
+ let currentProfileId: string | undefined;
+ let viewerTimezone: string | undefined;
+
+ if (user) {
+ isLoggedIn = true;
+ const persistedUser = await upsertDiscordUser(user);
+ const viewerProfile = await getProfileByUserId(persistedUser.id);
+ currentProfileId = viewerProfile?.profile.id;
+ savedProfileIds = await listSavedProfileIds(persistedUser.id);
+
+ if (viewerProfile) {
+ viewerTimezone = toViewerAvailabilityContext(
+ viewerProfile.profile,
+ ).timezone;
+ }
+ }
+
+ return (
+
+ );
+}
diff --git a/src/features/Discovery/DiscoveryPage.tsx b/src/features/Discovery/DiscoveryPage.tsx
index e6b3ef7..9be4cc8 100644
--- a/src/features/Discovery/DiscoveryPage.tsx
+++ b/src/features/Discovery/DiscoveryPage.tsx
@@ -41,6 +41,7 @@ import { ProfileGridSkeleton } from './ProfileGridSkeleton';
import { SearchBar } from './SearchBar';
import { SortMenu } from './SortMenu';
import { saveProfileRequest } from './saveProfileRequest';
+import { buildPublicProfileUrl, shareProfileUrl } from './shareProfile';
import { TagCloud } from './TagCloud';
const SEARCH_TRANSITION_MS = 320;
@@ -278,6 +279,55 @@ export const DiscoveryPage = ({
setPage(1);
};
+ const handleAddTagFilter = (tag: string) => {
+ setSelectedTags((previous) =>
+ previous.includes(tag) ? previous : [...previous, tag],
+ );
+ setPage(1);
+ };
+
+ const handleAddMultiFilter = (filterId: string, value: string) => {
+ setFilterValues((previous) => {
+ const current = previous[filterId];
+ const values = Array.isArray(current)
+ ? current
+ : current
+ ? [current]
+ : [];
+
+ if (values.includes(value)) {
+ return previous;
+ }
+
+ return { ...previous, [filterId]: [...values, value] };
+ });
+ setPage(1);
+ };
+
+ const handleViewProfile = (profileId: string) => {
+ router.push(`/${locale}/u/${profileId}`);
+ };
+
+ const handleShareProfile = async (profileId: string) => {
+ const result = await shareProfileUrl(
+ buildPublicProfileUrl(locale, profileId),
+ );
+
+ if (result === 'copied') {
+ addToast({
+ title: t('shareCopiedTitle'),
+ description: t('shareCopiedDescription'),
+ duration: BUMP_TOAST_DURATION,
+ });
+ } else if (result === 'error') {
+ addToast({
+ title: t('shareErrorTitle'),
+ description: t('shareErrorDescription'),
+ duration: BUMP_TOAST_DURATION,
+ });
+ }
+ };
+
const handlePageChange = (nextPage: number) => {
setPage(nextPage);
resultsHeadRef.current?.scrollIntoView({
@@ -449,6 +499,18 @@ export const DiscoveryPage = ({
currentProfileId={currentProfileId}
viewerTimezone={viewerTimezone}
onSaveProfile={saveProfileRequest}
+ onViewProfile={handleViewProfile}
+ onShare={handleShareProfile}
+ onTagClick={(tag) => handleAddTagFilter(tag)}
+ onLanguageClick={(language, _level, isPrimary) =>
+ handleAddMultiFilter(
+ isPrimary ? 'primaryLanguage' : 'targetLanguage',
+ language,
+ )
+ }
+ onCountryClick={(country) =>
+ handleAddMultiFilter('country', country)
+ }
addToast={addToast}
emptyState={
hasActiveFilters ? undefined : t('emptyFeedDescription')
diff --git a/src/features/Discovery/discoveryUrlState.ts b/src/features/Discovery/discoveryUrlState.ts
index 17a2701..c990496 100644
--- a/src/features/Discovery/discoveryUrlState.ts
+++ b/src/features/Discovery/discoveryUrlState.ts
@@ -86,6 +86,29 @@ export const parseDiscoveryState = (
};
};
+export type DiscoveryFilterTarget =
+ | 'tag'
+ | 'primaryLanguage'
+ | 'targetLanguage'
+ | 'country';
+
+const FILTER_TARGET_PARAM: Record = {
+ tag: TAGS_PARAM,
+ primaryLanguage: 'primary',
+ targetLanguage: 'target',
+ country: 'country',
+};
+
+export const buildDiscoveryFilterHref = (
+ locale: string,
+ target: DiscoveryFilterTarget,
+ value: string,
+): string => {
+ const params = new URLSearchParams();
+ params.set(FILTER_TARGET_PARAM[target], value);
+ return `/${locale}?${params.toString()}`;
+};
+
export const buildDiscoveryQuery = (
state: DiscoveryUrlState,
base?: ReadableParams,
diff --git a/src/features/Discovery/shareProfile.ts b/src/features/Discovery/shareProfile.ts
new file mode 100644
index 0000000..fa69b9b
--- /dev/null
+++ b/src/features/Discovery/shareProfile.ts
@@ -0,0 +1,30 @@
+export type ShareProfileResult = 'shared' | 'copied' | 'cancelled' | 'error';
+
+export const buildPublicProfileUrl = (locale: string, profileId: string) =>
+ `${window.location.origin}/${locale}/u/${profileId}`;
+
+export const shareProfileUrl = async (
+ url: string,
+ shareData?: { title?: string; text?: string },
+): Promise => {
+ if (
+ typeof navigator !== 'undefined' &&
+ typeof navigator.share === 'function'
+ ) {
+ try {
+ await navigator.share({ ...shareData, url });
+ return 'shared';
+ } catch (error) {
+ if (error instanceof DOMException && error.name === 'AbortError') {
+ return 'cancelled';
+ }
+ }
+ }
+
+ try {
+ await navigator.clipboard.writeText(url);
+ return 'copied';
+ } catch {
+ return 'error';
+ }
+};
diff --git a/src/features/Inbox/Inbox.tsx b/src/features/Inbox/Inbox.tsx
index 8e7f7ed..704f46b 100644
--- a/src/features/Inbox/Inbox.tsx
+++ b/src/features/Inbox/Inbox.tsx
@@ -16,6 +16,9 @@ const initializeNotifications = (initial: Notifications, premium: boolean) =>
.filter((notification) => premium || notification.kind === 'copy')
.map((n) => ({ ...n, read: false, isDeleting: false }));
+const notificationsSignature = (initial: Notifications, premium: boolean) =>
+ `${premium}:${initial.map((n) => n.id).join(',')}`;
+
export const Inbox = ({
notifications: initialNotifications,
premium = false,
@@ -31,16 +34,21 @@ export const Inbox = ({
);
const [currentPage, setCurrentPage] = useState(1);
const [isMounted, setIsMounted] = useState(false);
+ const [signature, setSignature] = useState(() =>
+ notificationsSignature(initialNotifications, premium),
+ );
const itemsPerPage = 5;
useEffect(() => {
setIsMounted(true);
}, []);
- useEffect(() => {
+ const nextSignature = notificationsSignature(initialNotifications, premium);
+ if (nextSignature !== signature) {
+ setSignature(nextSignature);
setNotifications(initializeNotifications(initialNotifications, premium));
setCurrentPage(1);
- }, [initialNotifications, premium]);
+ }
const unreadCount = notifications.filter((n) => !n.read).length;
diff --git a/src/locales/en.json b/src/locales/en.json
index 1191206..27be4cd 100644
--- a/src/locales/en.json
+++ b/src/locales/en.json
@@ -376,6 +376,10 @@
"saveLoginDescription": "Sign in with Discord to keep profiles you want to revisit.",
"saveError": "Couldn't update saved profiles",
"saveErrorDescription": "Something went wrong. Please try again.",
+ "shareCopiedTitle": "Profile link copied",
+ "shareCopiedDescription": "The public profile link is on your clipboard.",
+ "shareErrorTitle": "Couldn't share profile",
+ "shareErrorDescription": "Something went wrong. Please try again.",
"bumpedJustNow": "just now",
"bumpedMinutesAgo": "{count, plural, one {# minute ago} other {# minutes ago}}",
"bumpedHoursAgo": "{count, plural, one {# hour ago} other {# hours ago}}",
@@ -400,6 +404,9 @@
"emptyDescription": "Use the save action in any profile card menu to keep it here for later.",
"emptyAction": "Browse profiles"
},
+ "PublicProfile": {
+ "backToDiscovery": "Back to discovery"
+ },
"Onboarding": {
"firstRunSetup": "First-run setup",
"createTitle": "Create your language profile",
diff --git a/src/locales/ja.json b/src/locales/ja.json
index d7a543b..3243d39 100644
--- a/src/locales/ja.json
+++ b/src/locales/ja.json
@@ -376,6 +376,10 @@
"saveLoginDescription": "Discordでログインすると、気になるプロフィールを保存できます。",
"saveError": "保存を更新できませんでした",
"saveErrorDescription": "問題が発生しました。もう一度お試しください。",
+ "shareCopiedTitle": "プロフィールのリンクをコピーしました",
+ "shareCopiedDescription": "公開プロフィールのリンクをクリップボードにコピーしました。",
+ "shareErrorTitle": "プロフィールを共有できませんでした",
+ "shareErrorDescription": "問題が発生しました。もう一度お試しください。",
"bumpedJustNow": "たった今",
"bumpedMinutesAgo": "{count, plural, other {#分前}}",
"bumpedHoursAgo": "{count, plural, other {#時間前}}",
@@ -400,6 +404,9 @@
"emptyDescription": "プロフィールカードメニューの保存アクションを使うと、ここに保存して後で見返せます。",
"emptyAction": "プロフィールを見る"
},
+ "PublicProfile": {
+ "backToDiscovery": "ディスカバリーに戻る"
+ },
"Onboarding": {
"firstRunSetup": "初回セットアップ",
"createTitle": "言語プロフィールを作成",