Files
setrip/features/profile/components/profile-editor.tsx
T

336 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { updateProfileAction } from "@/features/profile/actions";
import { LIMITS } from "@/lib/limits";
import { VIBES, vibeMeta } from "@/lib/vibe";
import type { Vibe } from "@/app/generated/prisma/enums";
interface ProfileEditorProps {
userId: string;
initial: {
bio: string | null;
city: string | null;
interests: string[];
instagram: string | null;
vibe: Vibe | null;
} | null;
}
export function ProfileEditor({ userId, initial }: ProfileEditorProps) {
const router = useRouter();
const [open, setOpen] = useState(initial === null);
const [error, setError] = useState("");
const [success, setSuccess] = useState("");
const [loading, setLoading] = useState(false);
const [bio, setBio] = useState(initial?.bio ?? "");
const [city, setCity] = useState(initial?.city ?? "");
const [instagram, setInstagram] = useState(initial?.instagram ?? "");
const [interests, setInterests] = useState<string[]>(initial?.interests ?? []);
const [interestDraft, setInterestDraft] = useState("");
const [vibe, setVibe] = useState<Vibe | null>(initial?.vibe ?? null);
function addInterest() {
const v = interestDraft.trim().toLowerCase();
if (!v) return;
if (interests.includes(v)) {
setInterestDraft("");
return;
}
if (interests.length >= LIMITS.MAX_PROFILE_INTERESTS_COUNT) {
setError(`Maksimal ${LIMITS.MAX_PROFILE_INTERESTS_COUNT} minat`);
return;
}
setInterests([...interests, v]);
setInterestDraft("");
setError("");
}
function removeInterest(tag: string) {
setInterests(interests.filter((t) => t !== tag));
}
function handleInterestKeyDown(e: React.KeyboardEvent<HTMLInputElement>) {
if (e.key === "Enter" || e.key === ",") {
e.preventDefault();
addInterest();
}
}
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault();
setError("");
setSuccess("");
setLoading(true);
const formData = new FormData();
if (bio.trim()) formData.set("bio", bio.trim());
if (city.trim()) formData.set("city", city.trim());
if (instagram.trim()) formData.set("instagram", instagram.trim());
if (vibe) formData.set("vibe", vibe);
interests.forEach((t) => formData.append("interests", t));
const result = await updateProfileAction(formData);
setLoading(false);
if (result.error) {
setError(result.error);
} else {
setSuccess("Profil berhasil disimpan");
router.refresh();
}
}
if (!open) {
return (
<section className="rounded-2xl border border-neutral-200 bg-white p-4 shadow-sm sm:p-5">
<div className="flex items-center justify-between gap-3">
<div className="min-w-0">
<h2 className="text-sm font-bold text-neutral-800 sm:text-base">
Profil sosial
</h2>
<p className="mt-0.5 truncate text-xs text-neutral-500">
{initial?.city || initial?.bio
? "Profil terisi — klik untuk edit"
: "Lengkapi profil supaya orang lain mengenalmu"}
</p>
</div>
<div className="flex shrink-0 gap-2">
<a
href={`/u/${userId}`}
target="_blank"
rel="noopener noreferrer"
className="rounded-lg border border-neutral-200 px-3 py-1.5 text-xs font-medium text-neutral-600 hover:bg-neutral-50"
>
Lihat publik
</a>
<button
type="button"
onClick={() => setOpen(true)}
className="rounded-lg bg-primary-600 px-3 py-1.5 text-xs font-semibold text-white hover:bg-primary-700"
>
Edit profil
</button>
</div>
</div>
</section>
);
}
return (
<section className="rounded-2xl border border-neutral-200 bg-white p-5 shadow-sm sm:p-6">
<div className="mb-4 flex items-center justify-between gap-3">
<h2 className="text-base font-bold text-neutral-800 sm:text-lg">
Edit profil sosial
</h2>
<button
type="button"
onClick={() => setOpen(false)}
className="text-xs font-medium text-neutral-500 hover:text-neutral-700"
>
Tutup
</button>
</div>
{error && (
<div className="mb-4 rounded-xl bg-red-50 px-4 py-3 text-sm font-medium text-red-600">
{error}
</div>
)}
{success && (
<div className="mb-4 rounded-xl bg-secondary-50 px-4 py-3 text-sm font-medium text-secondary-700">
{success}
</div>
)}
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label
htmlFor="bio"
className="mb-1.5 block text-sm font-semibold text-neutral-700"
>
Bio singkat
</label>
<textarea
id="bio"
value={bio}
onChange={(e) => setBio(e.target.value)}
rows={3}
maxLength={LIMITS.MAX_PROFILE_BIO_LENGTH}
placeholder="Cerita singkat tentang kamu — vibe, gaya jalan, hal yang kamu cari di trip bareng..."
className="w-full rounded-xl border border-neutral-200 bg-neutral-50 px-4 py-2.5 text-sm text-neutral-800 placeholder:text-neutral-400 focus:bg-white"
/>
<p className="mt-1 text-right text-[11px] text-neutral-400">
{bio.length}/{LIMITS.MAX_PROFILE_BIO_LENGTH}
</p>
</div>
<div className="grid gap-4 sm:grid-cols-2">
<div>
<label
htmlFor="city"
className="mb-1.5 block text-sm font-semibold text-neutral-700"
>
Kota
</label>
<input
id="city"
type="text"
value={city}
onChange={(e) => setCity(e.target.value)}
maxLength={LIMITS.MAX_PROFILE_CITY_LENGTH}
placeholder="Bandung"
className="w-full rounded-xl border border-neutral-200 bg-neutral-50 px-4 py-2.5 text-sm text-neutral-800 placeholder:text-neutral-400 focus:bg-white"
/>
</div>
<div>
<label
htmlFor="instagram"
className="mb-1.5 block text-sm font-semibold text-neutral-700"
>
Instagram <span className="text-xs font-normal text-neutral-400">(opsional)</span>
</label>
<div className="flex items-center rounded-xl border border-neutral-200 bg-neutral-50 px-3 focus-within:bg-white">
<span className="text-sm text-neutral-400">@</span>
<input
id="instagram"
type="text"
value={instagram}
onChange={(e) =>
setInstagram(e.target.value.replace(/^@/, ""))
}
maxLength={LIMITS.MAX_PROFILE_INSTAGRAM_LENGTH}
placeholder="username"
className="w-full bg-transparent py-2.5 pl-1 text-sm text-neutral-800 placeholder:text-neutral-400 outline-none"
/>
</div>
</div>
</div>
<div>
<label
htmlFor="interest-input"
className="mb-1.5 block text-sm font-semibold text-neutral-700"
>
Minat aktivitas{" "}
<span className="text-xs font-normal text-neutral-400">
({interests.length}/{LIMITS.MAX_PROFILE_INTERESTS_COUNT})
</span>
</label>
<div className="mb-2 flex flex-wrap gap-1.5">
{interests.map((tag) => (
<span
key={tag}
className="inline-flex items-center gap-1 rounded-full bg-secondary-50 px-2.5 py-0.5 text-xs font-medium text-secondary-700"
>
#{tag}
<button
type="button"
onClick={() => removeInterest(tag)}
className="text-secondary-500 hover:text-red-600"
aria-label={`Hapus ${tag}`}
>
×
</button>
</span>
))}
</div>
<div className="flex gap-2">
<input
id="interest-input"
type="text"
value={interestDraft}
onChange={(e) => setInterestDraft(e.target.value)}
onKeyDown={handleInterestKeyDown}
maxLength={LIMITS.MAX_PROFILE_INTEREST_LENGTH}
placeholder="hiking, fotografi, yoga... (Enter untuk tambah)"
className="flex-1 rounded-xl border border-neutral-200 bg-neutral-50 px-4 py-2.5 text-sm text-neutral-800 placeholder:text-neutral-400 focus:bg-white"
/>
<button
type="button"
onClick={addInterest}
disabled={
interests.length >= LIMITS.MAX_PROFILE_INTERESTS_COUNT
}
className="rounded-xl border border-neutral-200 px-3 py-2.5 text-sm font-medium text-neutral-700 hover:bg-neutral-50 disabled:cursor-not-allowed disabled:opacity-50"
>
+ Tambah
</button>
</div>
</div>
<div>
<label className="mb-1.5 block text-sm font-semibold text-neutral-700">
Vibe jalanmu{" "}
<span className="text-xs font-normal text-neutral-400">(opsional)</span>
</label>
<p className="mb-2 text-[11px] text-neutral-500">
Bantu calon teman trip nyambung dengan ritme kamu.
</p>
<div className="flex flex-wrap gap-2">
<button
type="button"
onClick={() => setVibe(null)}
aria-pressed={vibe === null}
className={`rounded-full border px-3 py-1.5 text-xs font-medium transition-colors ${
vibe === null
? "border-neutral-700 bg-neutral-800 text-white"
: "border-neutral-200 bg-white text-neutral-600 hover:bg-neutral-50"
}`}
>
Belum diisi
</button>
{VIBES.map((v) => {
const m = vibeMeta(v);
const active = vibe === v;
return (
<button
key={v}
type="button"
onClick={() => setVibe(v)}
aria-pressed={active}
title={m.description}
className={`inline-flex items-center gap-1.5 rounded-full border px-3 py-1.5 text-xs font-medium transition-colors ${
active
? "border-primary-600 bg-primary-600 text-white"
: "border-neutral-200 bg-white text-neutral-600 hover:bg-neutral-50"
}`}
>
<span aria-hidden>{m.icon}</span>
<span>{m.label}</span>
</button>
);
})}
</div>
{vibe && (
<p className="mt-2 text-[11px] italic text-neutral-500">
{vibeMeta(vibe).description}
</p>
)}
</div>
<div className="flex gap-2 pt-2">
<button
type="submit"
disabled={loading}
className="rounded-xl bg-primary-600 px-5 py-2.5 text-sm font-bold text-white shadow-md shadow-primary-600/20 transition-colors hover:bg-primary-700 disabled:cursor-not-allowed disabled:opacity-50"
>
{loading ? "Menyimpan..." : "Simpan profil"}
</button>
<a
href={`/u/${userId}`}
target="_blank"
rel="noopener noreferrer"
className="rounded-xl border border-neutral-200 px-4 py-2.5 text-sm font-medium text-neutral-700 hover:bg-neutral-50"
>
Lihat publik
</a>
</div>
</form>
</section>
);
}