diff --git a/messages/de.json b/messages/de.json index b55f3f2..755bf5d 100644 --- a/messages/de.json +++ b/messages/de.json @@ -222,6 +222,13 @@ "required_field": "This field is required" }, "AdditionalPurchases": { + "banner": { + "title": "Amazing!", + "description": "Your journey begins now" + }, + "Progress": { + "final_step": "Access Your Results" + }, "caution": { "title": "Caution!", "description": "To prevent double charges please don`t close the page and don`t go back." @@ -238,7 +245,8 @@ "save": "Save {discount}%", "get_my_consultation": "Get my consultation", "skip_this_offer": "Skip this offer", - "payment_error": "Something went wrong. Please try again later." + "payment_error": "Something went wrong. Please try again later.", + "copyright": "© 2025, Wit Lab LLC, California, US" }, "add-guides": { "title": "Choose your sign-up offer 🔥", @@ -248,6 +256,7 @@ "payment_error": "Something went wrong. Please try again later.", "select_product_error": "Please select a product", "skip_offer": "Skip offer", + "copyright": "© 2025, Wit Lab LLC, California, US", "products": { "main_ultra_pack": { "title": "ULTRA PACK", @@ -288,6 +297,17 @@ "emoji": "rised_hand.webp" } } + }, + "video-guides": { + "title": "Choose your sign-up offer 🔥", + "subtitle": "Available only now", + "description": "* You will be charged for the add-on services or offers selected at the time of purchase. This is a non-recuring payment.", + "button": "Continue", + "skip_button": "Skip this offer and proceed further", + "copyright": "© 2025, Wit Lab LLC, California, US", + "now": "Now", + "payment_error": "Something went wrong. Please try again later.", + "select_product_error": "Please select a product" } }, "Chat": { @@ -398,4 +418,4 @@ }, "Soulmate": {} } -} \ No newline at end of file +} diff --git a/messages/en.json b/messages/en.json index bcdcc8b..4ad28d3 100644 --- a/messages/en.json +++ b/messages/en.json @@ -229,6 +229,13 @@ "required_field": "This field is required" }, "AdditionalPurchases": { + "banner": { + "title": "Amazing!", + "description": "Your journey begins now" + }, + "Progress": { + "final_step": "Access Your Results" + }, "caution": { "title": "Caution!", "description": "To prevent double charges please don`t close the page and don`t go back." @@ -245,7 +252,8 @@ "save": "Save {discount}%", "get_my_consultation": "Get my consultation", "skip_this_offer": "Skip this offer", - "payment_error": "Something went wrong. Please try again later." + "payment_error": "Something went wrong. Please try again later.", + "copyright": "© 2025, Wit Lab LLC, California, US" }, "add-guides": { "title": "Choose your sign-up offer 🔥", @@ -255,6 +263,7 @@ "payment_error": "Something went wrong. Please try again later.", "select_product_error": "Please select a product", "skip_offer": "Skip offer", + "copyright": "© 2025, Wit Lab LLC, California, US", "products": { "main_ultra_pack": { "title": "ULTRA PACK", @@ -297,54 +306,22 @@ } }, "video-guides": { - "banner": { - "title": "Amazing!", - "description": "Your journey begins now" - }, "title": "Choose your sign-up offer 🔥", "subtitle": "Available only now", "description": "* You will be charged for the add-on services or offers selected at the time of purchase. This is a non-recuring payment.", "button": "Continue", "skip_button": "Skip this offer and proceed further", "copyright": "© 2025, Wit Lab LLC, California, US", - "products": { - "main_ultra_pack": { - "title": "Ultra Pack", - "subtitle": "3 in 1+2 secret bonus readings", - "discount": "{discount}% OFF", - "price": "Now ", - "emoji": "star_struck.webp" - }, - "main_numerology_analysis": { - "title": "Relationship plan", - "subtitle": "Discover the future without losing yourself", - "discount": "{discount}% OFF", - "price": "Now ", - "emoji": "ring.webp" - }, - "main_tarot_reading": { - "title": "Healthy compatibility", - "subtitle": "Balance between closeness and freedom", - "discount": "{discount}% OFF", - "price": "Now ", - "emoji": "rose.webp" - }, - "main_palmistry_guide": { - "title": "How to talk about feelings", - "subtitle": "Express your emotions and be understood", - "discount": "{discount}% OFF", - "price": "Now ", - "emoji": "heart_from_hands.webp" - } - } + "now": "Now" + } + }, + "Dashboard": { + "adviser": { + "title": "Talk to an Astrologer" }, - "Progress": { - "items": { - "1": "Your Soulmate Portrait", - "2": "Video Guides", - "3": "Add Consultation", - "4": "Access Your Results" - } + "videoGuides": { + "now": "Now", + "purchaseFor": "Buy for {price}" } }, "Chat": { diff --git a/messages/es.json b/messages/es.json index 4dccde1..f57b0cc 100644 --- a/messages/es.json +++ b/messages/es.json @@ -222,6 +222,13 @@ "required_field": "This field is required" }, "AdditionalPurchases": { + "banner": { + "title": "Amazing!", + "description": "Your journey begins now" + }, + "Progress": { + "final_step": "Access Your Results" + }, "caution": { "title": "Caution!", "description": "To prevent double charges please don`t close the page and don`t go back." @@ -238,7 +245,8 @@ "save": "Save {discount}%", "get_my_consultation": "Get my consultation", "skip_this_offer": "Skip this offer", - "payment_error": "Something went wrong. Please try again later." + "payment_error": "Something went wrong. Please try again later.", + "copyright": "© 2025, Wit Lab LLC, California, US" }, "add-guides": { "title": "Choose your sign-up offer 🔥", @@ -248,6 +256,7 @@ "payment_error": "Something went wrong. Please try again later.", "select_product_error": "Please select a product", "skip_offer": "Skip offer", + "copyright": "© 2025, Wit Lab LLC, California, US", "products": { "main_ultra_pack": { "title": "ULTRA PACK", @@ -288,6 +297,17 @@ "emoji": "rised_hand.webp" } } + }, + "video-guides": { + "title": "Choose your sign-up offer 🔥", + "subtitle": "Available only now", + "description": "* You will be charged for the add-on services or offers selected at the time of purchase. This is a non-recuring payment.", + "button": "Continue", + "skip_button": "Skip this offer and proceed further", + "copyright": "© 2025, Wit Lab LLC, California, US", + "now": "Now", + "payment_error": "Something went wrong. Please try again later.", + "select_product_error": "Please select a product" } }, "Chat": { @@ -398,4 +418,4 @@ }, "Soulmate": {} } -} \ No newline at end of file +} diff --git a/src/app/[locale]/(additional-purchases)/ap/[pageType]/layout.tsx b/src/app/[locale]/(additional-purchases)/ap/[pageType]/layout.tsx index 8a311ed..04d878c 100644 --- a/src/app/[locale]/(additional-purchases)/ap/[pageType]/layout.tsx +++ b/src/app/[locale]/(additional-purchases)/ap/[pageType]/layout.tsx @@ -1,6 +1,9 @@ import { redirect } from "next/navigation"; -import { MultiPageNavigationProvider } from "@/components/domains/additional-purchases"; +import { + MultiPageNavigationProvider, + ProgressLayout, +} from "@/components/domains/additional-purchases"; import { loadFunnelPaymentById } from "@/entities/session/funnel/loaders"; import { ROUTES } from "@/shared/constants/client-routes"; import { ELocalesPlacement } from "@/types"; @@ -30,7 +33,7 @@ export default async function MultiPageLayout({ return ( - {children} + {children} ); } diff --git a/src/app/[locale]/(core)/page.tsx b/src/app/[locale]/(core)/page.tsx index 4f4e81f..b701641 100644 --- a/src/app/[locale]/(core)/page.tsx +++ b/src/app/[locale]/(core)/page.tsx @@ -11,6 +11,7 @@ import { PalmSection, PalmSectionSkeleton, PortraitsSection, + VideoGuidesSection, } from "@/components/domains/dashboard"; import { loadChatsList } from "@/entities/chats/loaders"; import { @@ -19,18 +20,25 @@ import { loadMeditations, loadPalms, loadPortraits, + loadVideoGuides, } from "@/entities/dashboard/loaders"; import styles from "./page.module.scss"; +// Force dynamic to always get fresh data +export const dynamic = "force-dynamic"; + export default async function Home() { const chatsPromise = loadChatsList(); const portraits = await loadPortraits(); + const videoGuides = await loadVideoGuides(); return (
+ + }> diff --git a/src/app/[locale]/(core)/video-guides/[id]/layout.tsx b/src/app/[locale]/(core)/video-guides/[id]/layout.tsx new file mode 100644 index 0000000..ef8c04f --- /dev/null +++ b/src/app/[locale]/(core)/video-guides/[id]/layout.tsx @@ -0,0 +1,7 @@ +export default function VideoGuideLayout({ + children, +}: { + children: React.ReactNode; +}) { + return <>{children}; +} diff --git a/src/app/[locale]/(core)/video-guides/[id]/loading.tsx b/src/app/[locale]/(core)/video-guides/[id]/loading.tsx new file mode 100644 index 0000000..ef6ee0e --- /dev/null +++ b/src/app/[locale]/(core)/video-guides/[id]/loading.tsx @@ -0,0 +1,22 @@ +import { Spinner } from "@/components/ui"; + +export default function Loading() { + return ( +
+ +
+ ); +} diff --git a/src/app/[locale]/(core)/video-guides/[id]/page.tsx b/src/app/[locale]/(core)/video-guides/[id]/page.tsx new file mode 100644 index 0000000..dfc43ea --- /dev/null +++ b/src/app/[locale]/(core)/video-guides/[id]/page.tsx @@ -0,0 +1,38 @@ +import { notFound } from "next/navigation"; + +import { VideoGuideView } from "@/components/domains/video-guides"; +import { DashboardData, DashboardSchema } from "@/entities/dashboard/types"; +import { http } from "@/shared/api/httpClient"; +import { API_ROUTES } from "@/shared/constants/api-routes"; + +// Force dynamic to always get fresh data +export const dynamic = "force-dynamic"; + +export default async function VideoGuidePage({ + params, +}: { + params: Promise<{ id: string }>; +}) { + const { id } = await params; + + // Get fresh dashboard data without cache + const dashboard = await http.get(API_ROUTES.dashboard(), { + cache: "no-store", + schema: DashboardSchema, + }); + + const videoGuide = dashboard.videoGuides?.find(v => v.id === id); + + if (!videoGuide || !videoGuide.isPurchased || !videoGuide.videoLink) { + notFound(); + } + + return ( + + ); +} diff --git a/src/app/[locale]/(email-marketing)/em/(soulmate)/s/v1/landing/page.tsx b/src/app/[locale]/(email-marketing)/em/(soulmate)/s/v1/landing/page.tsx index 09385af..83efe4c 100644 --- a/src/app/[locale]/(email-marketing)/em/(soulmate)/s/v1/landing/page.tsx +++ b/src/app/[locale]/(email-marketing)/em/(soulmate)/s/v1/landing/page.tsx @@ -34,7 +34,10 @@ function getUsernameFromEmail(email: string): string { export default async function EmailMarketingSoulmateV1Landing() { const [payment, user] = await Promise.all([ - loadFunnelPaymentById(payload, "main") as Promise, + loadFunnelPaymentById( + payload, + "main" + ) as Promise, loadUser(), ]); diff --git a/src/components/domains/additional-purchases/AddConsultantButton/AddConsultantButton.module.scss b/src/components/domains/additional-purchases/AddConsultantButton/AddConsultantButton.module.scss index cf8be48..e2127ad 100644 --- a/src/components/domains/additional-purchases/AddConsultantButton/AddConsultantButton.module.scss +++ b/src/components/domains/additional-purchases/AddConsultantButton/AddConsultantButton.module.scss @@ -1,26 +1,45 @@ -.container.container { - display: flex; - flex-direction: column; - -webkit-box-align: center; - align-items: center; - width: 100%; - height: fit-content; - max-width: 560px; +.container { position: fixed; - bottom: 0dvh; + bottom: calc(0dvh + 16px); left: 50%; transform: translateX(-50%); - margin-top: 0px; - padding-bottom: 20px; - padding-inline: 15px; - z-index: 5; -} + width: 100%; + padding-inline: 24px; + max-width: 560px; + height: fit-content; -.button.button { - padding-block: 16px; -} + & > .button { + padding-block: 20px; + background: linear-gradient(90deg, #3b82f6 0%, #2563eb 100%); + border-radius: 16px; + box-shadow: + 0px 5px 14px 0px #3b82f666, + 0px 4px 6px 0px #3b82f61a; -.skipButton.skipButton { - background-color: transparent; - text-decoration: underline; + & > .text { + font-size: 19px; + font-weight: 500; + line-height: 125%; + } + } + + & > .skipButton { + padding: 0; + min-height: none; + margin-top: 13px; + + & > .text { + font-size: 16px; + line-height: 24px; + color: #1f2937; + text-decoration: underline; + } + } + + & > .copyright { + font-size: 12px; + line-height: 16px; + color: #9ca3af; + margin-top: 20px; + } } diff --git a/src/components/domains/additional-purchases/AddConsultantButton/AddConsultantButton.tsx b/src/components/domains/additional-purchases/AddConsultantButton/AddConsultantButton.tsx index 94d3f24..be206bd 100644 --- a/src/components/domains/additional-purchases/AddConsultantButton/AddConsultantButton.tsx +++ b/src/components/domains/additional-purchases/AddConsultantButton/AddConsultantButton.tsx @@ -1,5 +1,6 @@ "use client"; +import { useState } from "react"; import { useTranslations } from "next-intl"; import { Button, Spinner, Typography } from "@/components/ui"; @@ -17,12 +18,14 @@ export default function AddConsultantButton() { const { addToast } = useToast(); const { navigation } = useMultiPageNavigationContext(); const data = navigation.currentItem; + const [isNavigating, setIsNavigating] = useState(false); const product = data?.variants?.[0]; const { handleSingleCheckout, isLoading } = useSingleCheckout({ - onSuccess: () => { - navigation.goToNext(); + onSuccess: async () => { + setIsNavigating(true); + await navigation.goToNext(); }, onError: _error => { addToast({ @@ -57,30 +60,36 @@ export default function AddConsultantButton() { navigation.goToNext(); }; + const isButtonDisabled = isLoading || isNavigating || !product; + return ( + + {t("copyright")} + ); } diff --git a/src/components/domains/additional-purchases/AddConsultantPage/AddConsultantPage.tsx b/src/components/domains/additional-purchases/AddConsultantPage/AddConsultantPage.tsx index 1b7eeb8..aa259d0 100644 --- a/src/components/domains/additional-purchases/AddConsultantPage/AddConsultantPage.tsx +++ b/src/components/domains/additional-purchases/AddConsultantPage/AddConsultantPage.tsx @@ -1,20 +1,18 @@ -import { useTranslations } from "next-intl"; +import { getTranslations } from "next-intl/server"; import { AddConsultantButton, - Caution, ConsultationTable, } from "@/components/domains/additional-purchases"; import { Card, Typography } from "@/components/ui"; import styles from "./AddConsultantPage.module.scss"; -export default function AddConsultantPage() { - const t = useTranslations("AdditionalPurchases.add-consultant"); +export default async function AddConsultantPage() { + const t = await getTranslations("AdditionalPurchases.add-consultant"); return ( <> - {t("title")} diff --git a/src/components/domains/additional-purchases/AddGuidesButton/AddGuidesButton.module.scss b/src/components/domains/additional-purchases/AddGuidesButton/AddGuidesButton.module.scss index 28a4f02..465d567 100644 --- a/src/components/domains/additional-purchases/AddGuidesButton/AddGuidesButton.module.scss +++ b/src/components/domains/additional-purchases/AddGuidesButton/AddGuidesButton.module.scss @@ -7,8 +7,26 @@ padding-inline: 24px; max-width: 560px; height: fit-content; -} -.button { - padding-block: 16px; + & > .button { + padding-block: 20px; + background: linear-gradient(90deg, #3b82f6 0%, #2563eb 100%); + border-radius: 16px; + box-shadow: + 0px 5px 14px 0px #3b82f666, + 0px 4px 6px 0px #3b82f61a; + + & > .text { + font-size: 19px; + font-weight: 500; + line-height: 125%; + } + } + + & > .copyright { + font-size: 12px; + line-height: 16px; + color: #9ca3af; + margin-top: 20px; + } } diff --git a/src/components/domains/additional-purchases/AddGuidesButton/AddGuidesButton.tsx b/src/components/domains/additional-purchases/AddGuidesButton/AddGuidesButton.tsx index 3e0a305..2213171 100644 --- a/src/components/domains/additional-purchases/AddGuidesButton/AddGuidesButton.tsx +++ b/src/components/domains/additional-purchases/AddGuidesButton/AddGuidesButton.tsx @@ -1,5 +1,6 @@ "use client"; +import { useState } from "react"; import { useTranslations } from "next-intl"; import { Button, Spinner, Typography } from "@/components/ui"; @@ -18,10 +19,12 @@ export default function AddGuidesButton() { const { addToast } = useToast(); const { selectedProduct } = useProductSelection(); const { navigation } = useMultiPageNavigationContext(); + const [isNavigating, setIsNavigating] = useState(false); const { handleSingleCheckout, isLoading } = useSingleCheckout({ - onSuccess: () => { - navigation.goToNext(); + onSuccess: async () => { + setIsNavigating(true); + await navigation.goToNext(); }, onError: _error => { addToast({ @@ -58,21 +61,26 @@ export default function AddGuidesButton() { const isSkipOffer = selectedProduct?.id === "main_skip_offer"; + const isButtonDisabled = isLoading || isNavigating; + return ( + + {t("copyright")} + ); } diff --git a/src/components/domains/additional-purchases/AddGuidesPage/AddGuidesPage.tsx b/src/components/domains/additional-purchases/AddGuidesPage/AddGuidesPage.tsx index c44f400..1e8e90a 100644 --- a/src/components/domains/additional-purchases/AddGuidesPage/AddGuidesPage.tsx +++ b/src/components/domains/additional-purchases/AddGuidesPage/AddGuidesPage.tsx @@ -1,9 +1,8 @@ import { Suspense } from "react"; -import { useTranslations } from "next-intl"; +import { getTranslations } from "next-intl/server"; import { AddGuidesButton, - Caution, Offers, OffersSkeleton, ProductSelectionProvider, @@ -12,12 +11,11 @@ import { Typography } from "@/components/ui"; import styles from "./AddGuidesPage.module.scss"; -export default function AddGuidesPage() { - const t = useTranslations("AdditionalPurchases.add-guides"); +export default async function AddGuidesPage() { + const t = await getTranslations("AdditionalPurchases.add-guides"); return ( - {t("title")} diff --git a/src/components/domains/additional-purchases/AdditionalPurchaseBanner/AdditionalPurchaseBanner.module.scss b/src/components/domains/additional-purchases/AdditionalPurchaseBanner/AdditionalPurchaseBanner.module.scss new file mode 100644 index 0000000..5ecfd76 --- /dev/null +++ b/src/components/domains/additional-purchases/AdditionalPurchaseBanner/AdditionalPurchaseBanner.module.scss @@ -0,0 +1,45 @@ +.container { + width: 100%; + padding: 18px 20px 25px 25px; + background: linear-gradient( + 90deg, + rgba(78, 205, 196, 0.1) 0%, + rgba(102, 126, 234, 0.1) 100% + ); + border: 1px solid #4ecdc433; + border-radius: 32px; + margin-top: 34px; + display: grid; + grid-template-columns: 48px 1fr; + gap: 16px; + + & > .iconContainer { + width: 48px; + height: 48px; + border-radius: 50%; + background-color: #4ecdc433; + display: flex; + align-items: center; + justify-content: center; + } + + & > .textContainer { + display: flex; + flex-direction: column; + align-items: flex-start; + + & > .title { + font-size: 18px; + font-weight: 700; + line-height: 28px; + color: #262626; + } + + & > .description { + font-size: 16px; + font-weight: 500; + line-height: 24px; + color: #525252; + } + } +} diff --git a/src/components/domains/additional-purchases/AdditionalPurchaseBanner/AdditionalPurchaseBanner.tsx b/src/components/domains/additional-purchases/AdditionalPurchaseBanner/AdditionalPurchaseBanner.tsx new file mode 100644 index 0000000..11b98d3 --- /dev/null +++ b/src/components/domains/additional-purchases/AdditionalPurchaseBanner/AdditionalPurchaseBanner.tsx @@ -0,0 +1,29 @@ +"use client"; + +import { useTranslations } from "next-intl"; + +import { Typography } from "@/components/ui"; + +import HeartIcon from "./HeartIcon.svg"; + +import styles from "./AdditionalPurchaseBanner.module.scss"; + +export default function AdditionalPurchaseBanner() { + const t = useTranslations("AdditionalPurchases.banner"); + + return ( +
+
+ +
+
+ + {t("title")} + + + {t("description")} + +
+
+ ); +} diff --git a/src/components/domains/additional-purchases/AdditionalPurchaseBanner/HeartIcon.svg b/src/components/domains/additional-purchases/AdditionalPurchaseBanner/HeartIcon.svg new file mode 100644 index 0000000..78e1716 --- /dev/null +++ b/src/components/domains/additional-purchases/AdditionalPurchaseBanner/HeartIcon.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/components/domains/additional-purchases/Progress/Progress.module.scss b/src/components/domains/additional-purchases/Progress/Progress.module.scss index 00f1217..b2ee372 100644 --- a/src/components/domains/additional-purchases/Progress/Progress.module.scss +++ b/src/components/domains/additional-purchases/Progress/Progress.module.scss @@ -1,13 +1,22 @@ .container { - position: relative; width: 100%; display: flex; flex-direction: row; - align-items: center; + align-items: flex-start; justify-content: space-between; + position: sticky; + top: 24px; + z-index: 7777; + + & > .blurGradient { + background: rgb(247 247 247 / 42%); + rotate: 180deg; + left: -24px; + right: -24px; + } & > * { - z-index: 1; + z-index: 8888; } & > .item { @@ -15,6 +24,7 @@ flex-direction: column; align-items: center; gap: 12px; + max-width: 80px; & > .marker { width: 32px; @@ -93,6 +103,6 @@ position: absolute; top: 15px; height: 2px; - z-index: 0; + z-index: 7777; } } diff --git a/src/components/domains/additional-purchases/Progress/Progress.tsx b/src/components/domains/additional-purchases/Progress/Progress.tsx index 3679b54..eecb8d1 100644 --- a/src/components/domains/additional-purchases/Progress/Progress.tsx +++ b/src/components/domains/additional-purchases/Progress/Progress.tsx @@ -4,34 +4,46 @@ import { useTranslations } from "next-intl"; import clsx from "clsx"; import { Icon, IconName, Typography } from "@/components/ui"; +import { BlurComponent } from "@/components/widgets"; import { useDynamicSize } from "@/hooks/DOM/useDynamicSize"; import styles from "./Progress.module.scss"; interface IProgressProps { + items: string[]; activeItemIndex: number; } -export default function Progress({ activeItemIndex }: IProgressProps) { +export default function Progress({ items, activeItemIndex }: IProgressProps) { const t = useTranslations("AdditionalPurchases.Progress"); + + const finalStep = t("final_step"); + const allItems = [...items, finalStep]; + const { width: containerWidth, elementRef } = useDynamicSize({ defaultWidth: 327, }); - const items = Object.values(t.raw("items") as Record); - const firstChild = elementRef.current?.childNodes[0] as HTMLElement; + const lastChild = elementRef.current?.childNodes[ - items.length - 1 + allItems.length - 1 ] as HTMLElement; + const leftIndent = ((firstChild?.getBoundingClientRect().width || 100) - 32) / 2; + const rightIndent = ((lastChild?.getBoundingClientRect().width || 76) - 32) / 2; return ( -
- {items.map((item, index) => ( + + {allItems.map((item, index) => (
-
+
); } diff --git a/src/components/domains/additional-purchases/ProgressLayout/ProgressLayout.tsx b/src/components/domains/additional-purchases/ProgressLayout/ProgressLayout.tsx new file mode 100644 index 0000000..70021e8 --- /dev/null +++ b/src/components/domains/additional-purchases/ProgressLayout/ProgressLayout.tsx @@ -0,0 +1,27 @@ +"use client"; + +import { IFunnelPaymentPlacement } from "@/entities/session/funnel/types"; + +import { Progress, useMultiPageNavigationContext } from ".."; + +interface IProgressLayoutProps { + children: React.ReactNode; +} + +export default function ProgressLayout({ children }: IProgressLayoutProps) { + const { navigation } = useMultiPageNavigationContext(); + + const progressItems = navigation.data.map((item: IFunnelPaymentPlacement) => { + return item.title || item.type || ""; + }); + + return ( + <> + + {children} + + ); +} diff --git a/src/components/domains/additional-purchases/VideoGuidesButton/VideoGuidesButton.tsx b/src/components/domains/additional-purchases/VideoGuidesButton/VideoGuidesButton.tsx index 9227b80..febc548 100644 --- a/src/components/domains/additional-purchases/VideoGuidesButton/VideoGuidesButton.tsx +++ b/src/components/domains/additional-purchases/VideoGuidesButton/VideoGuidesButton.tsx @@ -1,5 +1,6 @@ "use client"; +import { useState } from "react"; import { useTranslations } from "next-intl"; import { Button, Spinner, Typography } from "@/components/ui"; @@ -18,10 +19,12 @@ export default function VideoGuidesButton() { const { addToast } = useToast(); const { selectedProduct } = useProductSelection(); const { navigation } = useMultiPageNavigationContext(); + const [isNavigating, setIsNavigating] = useState(false); const { handleSingleCheckout, isLoading } = useSingleCheckout({ - onSuccess: () => { - navigation.goToNext(); + onSuccess: async () => { + setIsNavigating(true); + await navigation.goToNext(); }, onError: _error => { addToast({ @@ -56,14 +59,16 @@ export default function VideoGuidesButton() { navigation.goToNext(); }; + const isButtonDisabled = isLoading || isNavigating; + return (
diff --git a/src/components/domains/dashboard/cards/PortraitCard/PortraitCard.module.scss b/src/components/domains/dashboard/cards/PortraitCard/PortraitCard.module.scss index 79be070..9b37602 100644 --- a/src/components/domains/dashboard/cards/PortraitCard/PortraitCard.module.scss +++ b/src/components/domains/dashboard/cards/PortraitCard/PortraitCard.module.scss @@ -8,7 +8,9 @@ display: flex; flex-direction: column; cursor: pointer; - transition: transform 0.2s ease, box-shadow 0.2s ease; + transition: + transform 0.2s ease, + box-shadow 0.2s ease; &:hover { transform: translateY(-4px); @@ -102,14 +104,14 @@ } .statusDone { - color: #16A34A; + color: #16a34a; .statusText { - color: #16A34A; + color: #16a34a; } .checkmark { - color: #16A34A; + color: #16a34a; } } @@ -158,7 +160,9 @@ align-items: center; justify-content: center; cursor: pointer; - transition: background 0.2s ease, transform 0.1s ease; + transition: + background 0.2s ease, + transform 0.1s ease; flex-shrink: 0; svg { diff --git a/src/components/domains/dashboard/cards/PortraitCard/PortraitCard.tsx b/src/components/domains/dashboard/cards/PortraitCard/PortraitCard.tsx index cd7c3cc..ef6a83d 100644 --- a/src/components/domains/dashboard/cards/PortraitCard/PortraitCard.tsx +++ b/src/components/domains/dashboard/cards/PortraitCard/PortraitCard.tsx @@ -12,13 +12,22 @@ import styles from "./PortraitCard.module.scss"; type PortraitCardProps = PartnerPortrait; const HeartCheckIcon = () => ( - + - + - + @@ -35,21 +44,39 @@ const getStatusConfig = (status: PartnerPortrait["status"]) => { }; case "processing": return { - icon: , + icon: ( + + ), text: "Processing...", showCheckmark: false, className: styles.statusProcessing, }; case "queued": return { - icon: , + icon: ( + + ), text: "In Queue", showCheckmark: false, className: styles.statusQueued, }; case "error": return { - icon: , + icon: ( + + ), text: "Error", showCheckmark: false, className: styles.statusError, @@ -66,7 +93,10 @@ export default function PortraitCard({ const router = useRouter(); // Use polling hook to update status in real-time - const { status, imageUrl: polledImageUrl } = useGenerationStatus(_id, initialStatus); + const { status, imageUrl: polledImageUrl } = useGenerationStatus( + _id, + initialStatus + ); // Use polled imageUrl if available, otherwise use initial const imageUrl = polledImageUrl || initialImageUrl; @@ -84,7 +114,7 @@ export default function PortraitCard({
{imageUrl ? ( @@ -98,17 +128,32 @@ export default function PortraitCard({ /> ) : (
- +
)}
- + {title} - + Finding the One Guide
@@ -120,13 +165,21 @@ export default function PortraitCard({ {statusConfig.text} {statusConfig.showCheckmark && ( - + )}
{status === "done" && ( )} diff --git a/src/components/domains/dashboard/cards/VideoGuideCard/VideoGuideCard.module.scss b/src/components/domains/dashboard/cards/VideoGuideCard/VideoGuideCard.module.scss new file mode 100644 index 0000000..e5ee6e0 --- /dev/null +++ b/src/components/domains/dashboard/cards/VideoGuideCard/VideoGuideCard.module.scss @@ -0,0 +1,273 @@ +.container.container { + display: flex; + min-width: 260px; + min-height: 280px; + height: 100%; + flex-direction: column; + align-items: flex-start; + border-radius: 24px; + border: 0 solid #e5e7eb; + background: rgba(0, 0, 0, 0); + box-shadow: + 0 4px 6px 0 rgba(0, 0, 0, 0.1), + 0 10px 15px 0 rgba(0, 0, 0, 0.1); + overflow: hidden; + padding: 0; + transition: + transform 0.2s ease, + box-shadow 0.2s ease; + position: relative; + + // Hover effect only for purchased cards (wrapped in Link) + :global(a):hover & { + transform: translateY(-4px); + box-shadow: + 0 6px 10px 0 rgba(0, 0, 0, 0.12), + 0 12px 18px 0 rgba(0, 0, 0, 0.12); + } + + &.processing { + pointer-events: none; + } +} + +.processingOverlay { + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + display: flex; + align-items: center; + justify-content: center; + background: rgba(255, 255, 255, 0.95); + z-index: 10; +} + +// Image section +.image { + display: flex; + min-height: 160px; + padding: 16px; + flex-direction: column; + justify-content: center; + align-items: center; + gap: 10px; + align-self: stretch; + border: 0 solid #e5e7eb; + position: relative; + overflow: hidden; + + &::before { + content: ""; + position: absolute; + inset: 0; + background: lightgray 50% / cover no-repeat; + z-index: 0; + } + + .imageContent { + position: absolute; + inset: 0; + width: 100%; + height: 100%; + object-fit: cover; + object-position: center; + pointer-events: none; + z-index: 0; + } + + .playIcon { + position: relative; + z-index: 1; + width: 64px; + height: 65px; + + svg { + width: 64px; + height: 65px; + filter: drop-shadow(0 2px 12px rgba(0, 0, 0, 0.25)); + } + } +} + +// Content section +.content { + display: flex; + padding: 16px; + flex-direction: column; + align-items: flex-start; + justify-content: space-between; + gap: 24px; + align-self: stretch; + background: #fff; + flex: 1; + + .purchased & { + gap: 6px; + } +} + +// Top section +.top { + display: flex; + align-items: flex-start; + gap: 14px; + align-self: stretch; + + .text { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 3px; + flex: 1 0 0; + } + + .arrowButton { + display: flex; + width: 40px; + height: 40px; + padding: 12px 0; + justify-content: center; + align-items: center; + border-radius: 9999px; + border: 0 solid #e5e7eb; + background: #f5f5f7; + cursor: pointer; + transition: opacity 0.2s ease; + + svg { + width: 8px; + height: 14px; + flex-shrink: 0; + } + + &:hover { + opacity: 0.8; + } + } +} + +.title { + align-self: stretch; + color: #1d1d1f; + font-family: Inter, sans-serif; + font-size: 20px; + font-style: normal; + font-weight: 500; + line-height: 28px; + text-align: left; +} + +.subtitle { + align-self: stretch; + color: #6b7280; + font-family: Inter, sans-serif; + font-size: 16px; + font-style: normal; + font-weight: 400; + line-height: 20px; + text-align: left; +} + +// Bottom section +.bottom { + display: flex; + flex-direction: column; + justify-content: flex-end; + align-items: flex-end; + gap: 8px; + align-self: stretch; +} + +.bottomText { + display: flex; + height: 24px; + justify-content: space-between; + align-items: center; + align-self: stretch; +} + +.duration { + display: flex; + width: 49px; + flex-direction: column; + justify-content: center; + align-self: stretch; + color: #6b7280; + font-family: Inter, sans-serif; + font-size: 16px; + font-style: normal; + font-weight: 400; + line-height: 20px; +} + +.durationPurchased { + display: flex; + justify-content: flex-end; + align-self: stretch; + color: #6b7280; + font-family: Inter, sans-serif; + font-size: 16px; + font-style: normal; + font-weight: 400; + line-height: 20px; +} + +.discountBadge { + display: flex; + padding: 6px 10px; + justify-content: center; + align-items: center; + gap: 10px; + align-self: stretch; + border-radius: 9999px; + border: 0 solid #e5e7eb; + background: rgba(255, 107, 107, 0.1); +} + +.discountText { + color: #ff6b6b; + text-align: center; + font-family: Inter, sans-serif; + font-size: 12px; + font-style: normal; + font-weight: 700; + line-height: normal; + + .oldPrice { + color: #8b8b8b; + font-family: Inter, sans-serif; + font-size: 12px; + font-style: normal; + font-weight: 500; + line-height: normal; + text-decoration-line: line-through; + } +} + +.buyButton.buyButton { + display: flex; + padding: 8px 10px; + justify-content: center; + align-items: center; + gap: 10px; + border-radius: 12px; + border: 0 solid #e5e7eb; + background: #2563eb; + cursor: pointer; + transition: opacity 0.2s ease; + width: auto; + + color: #fff; + text-align: center; + font-family: Inter, sans-serif; + font-size: 14px; + font-style: normal; + font-weight: 500; + line-height: normal; + + &:hover { + opacity: 0.9; + } +} diff --git a/src/components/domains/dashboard/cards/VideoGuideCard/VideoGuideCard.tsx b/src/components/domains/dashboard/cards/VideoGuideCard/VideoGuideCard.tsx new file mode 100644 index 0000000..766a4ee --- /dev/null +++ b/src/components/domains/dashboard/cards/VideoGuideCard/VideoGuideCard.tsx @@ -0,0 +1,206 @@ +"use client"; + +import Image from "next/image"; +import { useTranslations } from "next-intl"; +import clsx from "clsx"; + +import { Button, Card, Spinner, Typography } from "@/components/ui"; +import { getFormattedPrice } from "@/shared/utils/price"; +import { Currency } from "@/types"; + +import styles from "./VideoGuideCard.module.scss"; + +interface VideoGuideCardProps { + name: string; + description: string; + imageUrl: string; + duration: string; + price: number; + oldPrice: number; + discount: number; + isPurchased: boolean; + isCheckoutLoading?: boolean; + isProcessingPurchase?: boolean; + onPurchaseClick?: () => void; + className?: string; +} + +export default function VideoGuideCard(props: VideoGuideCardProps) { + const { + name, + description, + imageUrl, + duration, + price, + oldPrice, + discount, + isPurchased, + isCheckoutLoading, + isProcessingPurchase, + onPurchaseClick, + className, + } = props; + + const tCommon = useTranslations("Dashboard.videoGuides"); + + const currency = Currency.USD; + + // Если идет обработка покупки - показываем только лоадер на всей карточке + if (isProcessingPurchase) { + return ( + +
+ +
+
+ ); + } + + return ( + + {/* Image with Play Icon */} +
+ {name} +
+ + + + + + + + + + + + + + + + + +
+
+ + {/* Content */} +
+ {/* Top Section */} +
+
+ + {name} + + + {description} + +
+ {isPurchased && ( + + )} +
+ + {/* Bottom Section */} +
+ {!isPurchased ? ( + <> +
+ + {duration} + +
+ + {discount}% OFF{" "} + + {getFormattedPrice(oldPrice, currency)} + + +
+
+ + + ) : ( + + {duration} + + )} +
+
+
+ ); +} diff --git a/src/components/domains/dashboard/cards/VideoGuideCard/index.ts b/src/components/domains/dashboard/cards/VideoGuideCard/index.ts new file mode 100644 index 0000000..e1d5d96 --- /dev/null +++ b/src/components/domains/dashboard/cards/VideoGuideCard/index.ts @@ -0,0 +1 @@ +export { default as VideoGuideCard } from "./VideoGuideCard"; diff --git a/src/components/domains/dashboard/cards/index.ts b/src/components/domains/dashboard/cards/index.ts index 65540f0..1a27805 100644 --- a/src/components/domains/dashboard/cards/index.ts +++ b/src/components/domains/dashboard/cards/index.ts @@ -3,3 +3,4 @@ export { default as CompatibilityCard } from "./CompatibilityCard/CompatibilityC export { default as MeditationCard } from "./MeditationCard/MeditationCard"; export { default as PalmCard } from "./PalmCard/PalmCard"; export { default as PortraitCard } from "./PortraitCard/PortraitCard"; +export { default as VideoGuideCard } from "./VideoGuideCard/VideoGuideCard"; diff --git a/src/components/domains/dashboard/sections/AdvisersSection/AdvisersSection.tsx b/src/components/domains/dashboard/sections/AdvisersSection/AdvisersSection.tsx index addacd3..b083fa3 100644 --- a/src/components/domains/dashboard/sections/AdvisersSection/AdvisersSection.tsx +++ b/src/components/domains/dashboard/sections/AdvisersSection/AdvisersSection.tsx @@ -31,7 +31,12 @@ export default function AdvisersSection({ }: AdvisersSectionProps) { const assistants = use(promiseAssistants); const chats = use(promiseChats); - const columns = getOptimalColumns(assistants?.length || 0); + + if (!assistants || assistants.length === 0) { + return null; + } + + const columns = getOptimalColumns(assistants.length); return (
diff --git a/src/components/domains/dashboard/sections/CompatibilitySection/CompatibilitySection.tsx b/src/components/domains/dashboard/sections/CompatibilitySection/CompatibilitySection.tsx index b0455c8..5289ff1 100644 --- a/src/components/domains/dashboard/sections/CompatibilitySection/CompatibilitySection.tsx +++ b/src/components/domains/dashboard/sections/CompatibilitySection/CompatibilitySection.tsx @@ -22,7 +22,12 @@ export default function CompatibilitySection({ gridDisplayMode = "horizontal", }: CompatibilitySectionProps) { const compatibilities = use(promise); - const columns = Math.ceil(compatibilities?.length / 2); + + if (!compatibilities || compatibilities.length === 0) { + return null; + } + + const columns = Math.ceil(compatibilities.length / 2); return (
diff --git a/src/components/domains/dashboard/sections/MeditationSection/MeditationSection.tsx b/src/components/domains/dashboard/sections/MeditationSection/MeditationSection.tsx index 45d9638..8e3de49 100644 --- a/src/components/domains/dashboard/sections/MeditationSection/MeditationSection.tsx +++ b/src/components/domains/dashboard/sections/MeditationSection/MeditationSection.tsx @@ -20,7 +20,12 @@ export default function MeditationSection({ gridDisplayMode = "horizontal", }: MeditationSectionProps) { const meditations = use(promise); - const columns = meditations?.length; + + if (!meditations || meditations.length === 0) { + return null; + } + + const columns = meditations.length; return (
diff --git a/src/components/domains/dashboard/sections/PalmSection/PalmSection.tsx b/src/components/domains/dashboard/sections/PalmSection/PalmSection.tsx index bffcc40..d6fa71a 100644 --- a/src/components/domains/dashboard/sections/PalmSection/PalmSection.tsx +++ b/src/components/domains/dashboard/sections/PalmSection/PalmSection.tsx @@ -15,7 +15,12 @@ export default function PalmSection({ promise: Promise; }) { const palms = use(promise); - const columns = palms?.length; + + if (!palms || palms.length === 0) { + return null; + } + + const columns = palms.length; return (
diff --git a/src/components/domains/dashboard/sections/VideoGuidesSection/VideoGuidesSection.module.scss b/src/components/domains/dashboard/sections/VideoGuidesSection/VideoGuidesSection.module.scss new file mode 100644 index 0000000..4bcbd0d --- /dev/null +++ b/src/components/domains/dashboard/sections/VideoGuidesSection/VideoGuidesSection.module.scss @@ -0,0 +1,21 @@ +.sectionContent.sectionContent { + overflow-x: scroll; + -webkit-overflow-scrolling: touch; + width: calc(100% + 32px); + padding: 20px 16px 24px 16px; + padding-right: 0; + margin: -20px -16px -24px -16px; +} + +.grid { + padding-right: 16px; + grid-auto-rows: 1fr; + + a, + > div { + text-decoration: none; + color: inherit; + display: block; + height: 100%; + } +} diff --git a/src/components/domains/dashboard/sections/VideoGuidesSection/VideoGuidesSection.tsx b/src/components/domains/dashboard/sections/VideoGuidesSection/VideoGuidesSection.tsx new file mode 100644 index 0000000..13de83d --- /dev/null +++ b/src/components/domains/dashboard/sections/VideoGuidesSection/VideoGuidesSection.tsx @@ -0,0 +1,81 @@ +"use client"; + +import Link from "next/link"; + +import { Grid, Section } from "@/components/ui"; +import { VideoGuide } from "@/entities/dashboard/types"; +import { useVideoGuidePurchase } from "@/hooks/video-guides/useVideoGuidePurchase"; + +import { VideoGuideCard } from "../../cards"; + +import styles from "./VideoGuidesSection.module.scss"; + +interface VideoGuidesSectionProps { + videoGuides: VideoGuide[]; +} + +function VideoGuideCardWrapper({ videoGuide }: { videoGuide: VideoGuide }) { + const { handlePurchase, isCheckoutLoading, isProcessingPurchase } = + useVideoGuidePurchase({ + videoGuideId: videoGuide.id, + productId: videoGuide.id, + productKey: videoGuide.key, + }); + + // Для купленных видео - ссылка на страницу просмотра + const href = + videoGuide.isPurchased && videoGuide.videoLink + ? `/video-guides/${videoGuide.id}` + : "#"; + + const isClickable = videoGuide.isPurchased && videoGuide.videoLink; + + const cardElement = ( + + ); + + if (isClickable) { + return ( + + {cardElement} + + ); + } + + return
{cardElement}
; +} + +export default function VideoGuidesSection({ + videoGuides, +}: VideoGuidesSectionProps) { + if (!videoGuides || videoGuides.length === 0) { + return null; + } + + const columns = videoGuides.length; + + return ( +
+ + {videoGuides.map(videoGuide => ( + + ))} + +
+ ); +} diff --git a/src/components/domains/dashboard/sections/VideoGuidesSection/index.ts b/src/components/domains/dashboard/sections/VideoGuidesSection/index.ts new file mode 100644 index 0000000..444247a --- /dev/null +++ b/src/components/domains/dashboard/sections/VideoGuidesSection/index.ts @@ -0,0 +1 @@ +export { default as VideoGuidesSection } from "./VideoGuidesSection"; diff --git a/src/components/domains/dashboard/sections/index.ts b/src/components/domains/dashboard/sections/index.ts index 989e957..bc600d1 100644 --- a/src/components/domains/dashboard/sections/index.ts +++ b/src/components/domains/dashboard/sections/index.ts @@ -20,3 +20,4 @@ export { PalmSectionSkeleton, } from "./PalmSection/PalmSection"; export { default as PortraitsSection } from "./PortraitsSection/PortraitsSection"; +export { default as VideoGuidesSection } from "./VideoGuidesSection/VideoGuidesSection"; diff --git a/src/components/domains/email-marketing/soulmate/v1/DetailedPortraitCard/DetailedPortraitCard.tsx b/src/components/domains/email-marketing/soulmate/v1/DetailedPortraitCard/DetailedPortraitCard.tsx index 1fc6854..325ff38 100644 --- a/src/components/domains/email-marketing/soulmate/v1/DetailedPortraitCard/DetailedPortraitCard.tsx +++ b/src/components/domains/email-marketing/soulmate/v1/DetailedPortraitCard/DetailedPortraitCard.tsx @@ -12,9 +12,7 @@ import styles from "./DetailedPortraitCard.module.scss"; export default function DetailedPortraitCard() { const t = useTranslations( - translatePathEmailMarketingSoulmateV1( - "Landing.what-get.detailed-portrait" - ) + translatePathEmailMarketingSoulmateV1("Landing.what-get.detailed-portrait") ); const { user } = useUser(); const gender = user?.profile?.gender; diff --git a/src/components/domains/email-marketing/soulmate/v1/GuaranteedSecurityPayments/GuaranteedSecurityPayments.tsx b/src/components/domains/email-marketing/soulmate/v1/GuaranteedSecurityPayments/GuaranteedSecurityPayments.tsx index 5c084e2..d1c117b 100644 --- a/src/components/domains/email-marketing/soulmate/v1/GuaranteedSecurityPayments/GuaranteedSecurityPayments.tsx +++ b/src/components/domains/email-marketing/soulmate/v1/GuaranteedSecurityPayments/GuaranteedSecurityPayments.tsx @@ -8,9 +8,7 @@ import { translatePathEmailMarketingSoulmateV1 } from "@/shared/constants/transl import styles from "./GuaranteedSecurityPayments.module.scss"; export default function GuaranteedSecurityPayments() { - const t = useTranslations( - translatePathEmailMarketingSoulmateV1("Landing") - ); + const t = useTranslations(translatePathEmailMarketingSoulmateV1("Landing")); return (
; diff --git a/src/components/domains/email-marketing/soulmate/v1/LandingButtonWrapper/LandingButtonWrapper.tsx b/src/components/domains/email-marketing/soulmate/v1/LandingButtonWrapper/LandingButtonWrapper.tsx index 33ef6ef..d9353a2 100644 --- a/src/components/domains/email-marketing/soulmate/v1/LandingButtonWrapper/LandingButtonWrapper.tsx +++ b/src/components/domains/email-marketing/soulmate/v1/LandingButtonWrapper/LandingButtonWrapper.tsx @@ -12,9 +12,7 @@ import styles from "./LandingButtonWrapper.module.scss"; export default function LandingButtonWrapper() { const router = useRouter(); - const t = useTranslations( - translatePathEmailMarketingSoulmateV1("Landing") - ); + const t = useTranslations(translatePathEmailMarketingSoulmateV1("Landing")); const handleContinue = () => { router.push(ROUTES.emailMarketingSoulmateV1SpecialOffer()); diff --git a/src/components/domains/email-marketing/soulmate/v1/Payments/Payments.tsx b/src/components/domains/email-marketing/soulmate/v1/Payments/Payments.tsx index 1737f94..8b7384a 100644 --- a/src/components/domains/email-marketing/soulmate/v1/Payments/Payments.tsx +++ b/src/components/domains/email-marketing/soulmate/v1/Payments/Payments.tsx @@ -1,8 +1,6 @@ import Image from "next/image"; -import { - emailMarketingCompV2Images, -} from "@/shared/constants/images"; +import { emailMarketingCompV2Images } from "@/shared/constants/images"; import styles from "./Payments.module.scss"; diff --git a/src/components/domains/email-marketing/soulmate/v1/TrialIntervalOffer/TrialIntervalOffer.tsx b/src/components/domains/email-marketing/soulmate/v1/TrialIntervalOffer/TrialIntervalOffer.tsx index f07aa6f..f533e28 100644 --- a/src/components/domains/email-marketing/soulmate/v1/TrialIntervalOffer/TrialIntervalOffer.tsx +++ b/src/components/domains/email-marketing/soulmate/v1/TrialIntervalOffer/TrialIntervalOffer.tsx @@ -19,9 +19,7 @@ export default async function TrialIntervalOffer({ newTrialInterval, }: ITrialIntervalOfferProps) { const t = await getTranslations( - translatePathEmailMarketingSoulmateV1( - "Landing.special-offer.trial-offer" - ) + translatePathEmailMarketingSoulmateV1("Landing.special-offer.trial-offer") ); return ( diff --git a/src/components/domains/portraits/PortraitView/PortraitView.module.scss b/src/components/domains/portraits/PortraitView/PortraitView.module.scss index 677f344..e36f756 100644 --- a/src/components/domains/portraits/PortraitView/PortraitView.module.scss +++ b/src/components/domains/portraits/PortraitView/PortraitView.module.scss @@ -7,6 +7,8 @@ display: flex; flex-direction: column; overflow: hidden; + z-index: 1000; + background: var(--background); } .header { diff --git a/src/components/domains/portraits/PortraitView/PortraitView.tsx b/src/components/domains/portraits/PortraitView/PortraitView.tsx index 0d1e671..c69d401 100644 --- a/src/components/domains/portraits/PortraitView/PortraitView.tsx +++ b/src/components/domains/portraits/PortraitView/PortraitView.tsx @@ -14,7 +14,11 @@ interface PortraitViewProps { result?: string | null; } -export default function PortraitView({ title, imageUrl, result }: PortraitViewProps) { +export default function PortraitView({ + title, + imageUrl, + result, +}: PortraitViewProps) { const router = useRouter(); const handleDownload = async () => { @@ -42,7 +46,12 @@ export default function PortraitView({ title, imageUrl, result }: PortraitViewPr - + {title}
@@ -67,28 +76,87 @@ export default function PortraitView({ title, imageUrl, result }: PortraitViewPr onClick={handleDownload} aria-label="Download portrait" > - + - - - - - + + + + + - - - - - - - - - - - - - + + + + + + + + + + + + + diff --git a/src/components/domains/video-guides/VideoGuideView/VideoGuideView.module.scss b/src/components/domains/video-guides/VideoGuideView/VideoGuideView.module.scss new file mode 100644 index 0000000..943df36 --- /dev/null +++ b/src/components/domains/video-guides/VideoGuideView/VideoGuideView.module.scss @@ -0,0 +1,96 @@ +.container { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + display: flex; + flex-direction: column; + overflow: hidden; + z-index: 1000; + background: var(--background); +} + +.header { + display: flex; + align-items: center; + gap: 12px; + padding: 16px; + background: var(--background); + position: relative; + flex-shrink: 0; +} + +.backButton { + width: 40px; + height: 40px; + border-radius: 50%; + background: #f5f5f5; + display: flex; + align-items: center; + justify-content: center; + border: none; + cursor: pointer; + flex-shrink: 0; + transition: background 0.2s ease; + + &:hover { + background: #e0e0e0; + } + + &:active { + background: #d0d0d0; + } +} + +.title { + flex: 1; + text-align: center; + padding-right: 40px; // Compensate for back button width +} + +.contentWrapper { + flex: 1; + display: flex; + flex-direction: column; + align-items: center; + padding: 24px; + overflow-y: auto; + gap: 32px; +} + +.videoContainer { + position: relative; + width: 100%; + max-width: 800px; + aspect-ratio: 16 / 9; + border-radius: 24px; + overflow: hidden; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08); +} + +.videoInner { + position: relative; + width: 100%; + height: 100%; +} + +.video { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + border: none; +} + +.descriptionWrapper { + width: 100%; + max-width: 800px; + padding: 0; +} + +.description { + color: #646464; + line-height: 1.6; +} diff --git a/src/components/domains/video-guides/VideoGuideView/VideoGuideView.tsx b/src/components/domains/video-guides/VideoGuideView/VideoGuideView.tsx new file mode 100644 index 0000000..57319a8 --- /dev/null +++ b/src/components/domains/video-guides/VideoGuideView/VideoGuideView.tsx @@ -0,0 +1,85 @@ +"use client"; + +import { useRouter } from "next/navigation"; + +import { Icon, IconName, Typography } from "@/components/ui"; + +import styles from "./VideoGuideView.module.scss"; + +interface VideoGuideViewProps { + id: string; + name: string; + description: string; + videoLink: string; +} + +export default function VideoGuideView({ + name, + description, + videoLink, +}: VideoGuideViewProps) { + const router = useRouter(); + + // Extract video ID from various YouTube URL formats + const getYouTubeVideoId = (url: string): string | null => { + const patterns = [ + /(?:youtube\.com\/watch\?v=|youtu\.be\/|youtube\.com\/embed\/)([^&\n?#]+)/, + /^([a-zA-Z0-9_-]{11})$/, // Direct video ID + ]; + + for (const pattern of patterns) { + const match = url.match(pattern); + if (match) return match[1]; + } + return null; + }; + + const videoId = getYouTubeVideoId(videoLink); + const embedUrl = videoId + ? `https://www.youtube.com/embed/${videoId}?rel=0&modestbranding=1` + : videoLink; + + return ( +
+ {/* Header with back button and title */} +
+ + + {name} + +
+ + {/* Video and Description */} +
+ {/* Video Player */} +
+
+