Initial commit
This commit is contained in:
+249
@@ -0,0 +1,249 @@
|
||||
import type { SupabaseClient } from '@supabase/supabase-js';
|
||||
import type { Locale } from './i18n';
|
||||
|
||||
export type EventStatus = 'draft' | 'published';
|
||||
|
||||
export interface EventRow {
|
||||
id: string;
|
||||
slug: string;
|
||||
title: { ua: string; en: string };
|
||||
description: { ua: string; en: string };
|
||||
cover_image_url: string | null;
|
||||
starts_at: string;
|
||||
ends_at: string;
|
||||
venue_name: string | null;
|
||||
status: EventStatus;
|
||||
created_by: string | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface ReviewRow {
|
||||
id: string;
|
||||
user_id: string;
|
||||
rating: number;
|
||||
comment: string | null;
|
||||
created_at: string;
|
||||
user: { full_name: string | null; avatar_url: string | null } | null;
|
||||
event_id?: string | null;
|
||||
}
|
||||
|
||||
export interface MessageRow {
|
||||
id: string;
|
||||
name: string;
|
||||
email: string;
|
||||
message: string;
|
||||
read: boolean;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export type EventsFilter = 'all' | 'upcoming' | 'past';
|
||||
|
||||
export function titleFor(locale: Locale, event: EventRow): string {
|
||||
return event.title?.[locale] || event.title?.['ua'] || event.title?.['en'] || '';
|
||||
}
|
||||
|
||||
export function descriptionFor(locale: Locale, event: EventRow): string {
|
||||
return event.description?.[locale] || event.description?.['ua'] || event.description?.['en'] || '';
|
||||
}
|
||||
|
||||
export async function getPublishedEvents(
|
||||
supabase: SupabaseClient,
|
||||
filter: EventsFilter,
|
||||
query: string,
|
||||
limit = 50,
|
||||
): Promise<EventRow[]> {
|
||||
const now = new Date().toISOString();
|
||||
let request = supabase
|
||||
.from('events')
|
||||
.select('*')
|
||||
.eq('status', 'published')
|
||||
.order('starts_at', { ascending: filter !== 'past' })
|
||||
.limit(limit);
|
||||
|
||||
if (filter === 'upcoming') request = request.gte('starts_at', now);
|
||||
if (filter === 'past') request = request.lt('starts_at', now);
|
||||
if (query.trim()) {
|
||||
request = request.or(`slug.ilike.%${query}%,title->>ua.ilike.%${query}%,title->>en.ilike.%${query}%`);
|
||||
}
|
||||
|
||||
const { data, error } = await request;
|
||||
if (error) {
|
||||
console.error('getPublishedEvents error', error);
|
||||
return [];
|
||||
}
|
||||
return (data ?? []) as EventRow[];
|
||||
}
|
||||
|
||||
export async function getUpcomingEvents(supabase: SupabaseClient, limit = 3): Promise<EventRow[]> {
|
||||
const { data, error } = await supabase
|
||||
.from('events')
|
||||
.select('*')
|
||||
.eq('status', 'published')
|
||||
.gte('starts_at', new Date().toISOString())
|
||||
.order('starts_at', { ascending: true })
|
||||
.limit(limit);
|
||||
|
||||
if (error) {
|
||||
console.error('getUpcomingEvents error', error);
|
||||
return [];
|
||||
}
|
||||
return (data ?? []) as EventRow[];
|
||||
}
|
||||
|
||||
export async function getEventBySlug(
|
||||
supabase: SupabaseClient,
|
||||
slug: string,
|
||||
): Promise<EventRow | null> {
|
||||
const { data, error } = await supabase
|
||||
.from('events')
|
||||
.select('*')
|
||||
.eq('slug', slug)
|
||||
.eq('status', 'published')
|
||||
.maybeSingle();
|
||||
|
||||
if (error) {
|
||||
console.error('getEventBySlug error', error);
|
||||
return null;
|
||||
}
|
||||
return (data ?? null) as EventRow | null;
|
||||
}
|
||||
|
||||
export async function getEventReviews(
|
||||
supabase: SupabaseClient,
|
||||
eventId: string,
|
||||
): Promise<ReviewRow[]> {
|
||||
const { data, error } = await supabase
|
||||
.from('event_reviews')
|
||||
.select('*, user:profiles(full_name, avatar_url)')
|
||||
.eq('event_id', eventId)
|
||||
.order('created_at', { ascending: false });
|
||||
|
||||
if (error) {
|
||||
console.error('getEventReviews error', error);
|
||||
return [];
|
||||
}
|
||||
return (data ?? []) as ReviewRow[];
|
||||
}
|
||||
|
||||
export async function getVenueReviews(supabase: SupabaseClient, limit = 50): Promise<ReviewRow[]> {
|
||||
const { data, error } = await supabase
|
||||
.from('venue_reviews')
|
||||
.select('*, user:profiles(full_name, avatar_url)')
|
||||
.order('created_at', { ascending: false })
|
||||
.limit(limit);
|
||||
|
||||
if (error) {
|
||||
console.error('getVenueReviews error', error);
|
||||
return [];
|
||||
}
|
||||
return (data ?? []) as ReviewRow[];
|
||||
}
|
||||
|
||||
// Admin helpers
|
||||
|
||||
export async function getAdminEvents(supabase: SupabaseClient): Promise<EventRow[]> {
|
||||
const { data, error } = await supabase
|
||||
.from('events')
|
||||
.select('*')
|
||||
.order('starts_at', { ascending: false })
|
||||
.limit(200);
|
||||
|
||||
if (error) {
|
||||
console.error('getAdminEvents error', error);
|
||||
return [];
|
||||
}
|
||||
return (data ?? []) as EventRow[];
|
||||
}
|
||||
|
||||
export async function getAdminEvent(supabase: SupabaseClient, id: string): Promise<EventRow | null> {
|
||||
const { data, error } = await supabase
|
||||
.from('events')
|
||||
.select('*')
|
||||
.eq('id', id)
|
||||
.maybeSingle();
|
||||
|
||||
if (error) {
|
||||
console.error('getAdminEvent error', error);
|
||||
return null;
|
||||
}
|
||||
return (data ?? null) as EventRow | null;
|
||||
}
|
||||
|
||||
export async function getAdminStats(supabase: SupabaseClient) {
|
||||
const [published, drafts, unread, eventReviews, venueReviews] = await Promise.all([
|
||||
supabase.from('events').select('id', { count: 'exact', head: true }).eq('status', 'published'),
|
||||
supabase.from('events').select('id', { count: 'exact', head: true }).eq('status', 'draft'),
|
||||
supabase
|
||||
.from('contact_messages')
|
||||
.select('id', { count: 'exact', head: true })
|
||||
.eq('read', false),
|
||||
supabase.from('event_reviews').select('id', { count: 'exact', head: true }),
|
||||
supabase.from('venue_reviews').select('id', { count: 'exact', head: true }),
|
||||
]);
|
||||
|
||||
return {
|
||||
published: published.count ?? 0,
|
||||
drafts: drafts.count ?? 0,
|
||||
unread: unread.count ?? 0,
|
||||
reviews: (eventReviews.count ?? 0) + (venueReviews.count ?? 0),
|
||||
};
|
||||
}
|
||||
|
||||
export interface AdminReviewRow {
|
||||
id: string;
|
||||
type: 'event' | 'venue';
|
||||
rating: number;
|
||||
comment: string | null;
|
||||
created_at: string;
|
||||
user_name: string | null;
|
||||
event_title: string | null;
|
||||
}
|
||||
|
||||
export async function getAdminReviews(supabase: SupabaseClient): Promise<AdminReviewRow[]> {
|
||||
const [eventReviews, venueReviews] = await Promise.all([
|
||||
supabase
|
||||
.from('event_reviews')
|
||||
.select('id, rating, comment, created_at, user:profiles(full_name), event:events(title)'),
|
||||
supabase
|
||||
.from('venue_reviews')
|
||||
.select('id, rating, comment, created_at, user:profiles(full_name)'),
|
||||
]);
|
||||
|
||||
const events: AdminReviewRow[] = (eventReviews.data ?? []).map((r: any) => ({
|
||||
id: r.id,
|
||||
type: 'event',
|
||||
rating: r.rating,
|
||||
comment: r.comment,
|
||||
created_at: r.created_at,
|
||||
user_name: r.user?.full_name ?? null,
|
||||
event_title: r.event?.title?.['ua'] ?? r.event?.title?.['en'] ?? null,
|
||||
}));
|
||||
const venue: AdminReviewRow[] = (venueReviews.data ?? []).map((r: any) => ({
|
||||
id: r.id,
|
||||
type: 'venue',
|
||||
rating: r.rating,
|
||||
comment: r.comment,
|
||||
created_at: r.created_at,
|
||||
user_name: r.user?.full_name ?? null,
|
||||
event_title: null,
|
||||
}));
|
||||
|
||||
return [...events, ...venue].sort((a, b) =>
|
||||
b.created_at.localeCompare(a.created_at),
|
||||
);
|
||||
}
|
||||
|
||||
export async function getAdminMessages(supabase: SupabaseClient): Promise<MessageRow[]> {
|
||||
const { data, error } = await supabase
|
||||
.from('contact_messages')
|
||||
.select('*')
|
||||
.order('created_at', { ascending: false })
|
||||
.limit(200);
|
||||
|
||||
if (error) {
|
||||
console.error('getAdminMessages error', error);
|
||||
return [];
|
||||
}
|
||||
return (data ?? []) as MessageRow[];
|
||||
}
|
||||
@@ -0,0 +1,231 @@
|
||||
import type { Dict } from './ua';
|
||||
|
||||
export const en: Dict = {
|
||||
meta: {
|
||||
siteName: 'Devodytyji',
|
||||
homeTitle: 'Devodytyji — open-air cultural venue',
|
||||
homeDescription:
|
||||
'Devodytyji is an open-air cultural space: concerts, movies, talks and markets under the open sky.',
|
||||
defaultOgImage: '/og-cover.jpg',
|
||||
},
|
||||
nav: {
|
||||
home: 'Home',
|
||||
events: 'Events',
|
||||
about: 'About',
|
||||
map: 'How to get there',
|
||||
contacts: 'Contacts',
|
||||
admin: 'Admin panel',
|
||||
login: 'Sign in',
|
||||
register: 'Sign up',
|
||||
logout: 'Log out',
|
||||
},
|
||||
common: {
|
||||
loading: 'Loading…',
|
||||
saving: 'Saving…',
|
||||
save: 'Save',
|
||||
cancel: 'Cancel',
|
||||
submit: 'Submit',
|
||||
close: 'Close',
|
||||
search: 'Search',
|
||||
all: 'All',
|
||||
upcoming: 'Upcoming',
|
||||
past: 'Past',
|
||||
published: 'Published',
|
||||
draft: 'Draft',
|
||||
edit: 'Edit',
|
||||
delete: 'Delete',
|
||||
read: 'Read',
|
||||
unread: 'Unread',
|
||||
back: 'Back',
|
||||
noResults: 'Nothing found',
|
||||
error: 'Something went wrong',
|
||||
success: 'Success',
|
||||
required: 'Required field',
|
||||
optional: 'optional',
|
||||
reviews: 'reviews',
|
||||
rating: 'Rating',
|
||||
writeReview: 'Write a review',
|
||||
view: 'View',
|
||||
minutes: 'min',
|
||||
},
|
||||
hero: {
|
||||
badge: 'Open air • Always open',
|
||||
title: 'A cultural venue under the open sky',
|
||||
subtitle:
|
||||
'Devodytyji is a space for concerts, movies, talks and markets. One yard, one stage, endless stories.',
|
||||
ctaEvents: 'See events',
|
||||
ctaMap: 'Get directions',
|
||||
},
|
||||
home: {
|
||||
nextEvents: 'Upcoming events',
|
||||
nextEventsSubtitle: 'What is happening at the venue in the near future.',
|
||||
viewAllEvents: 'All events',
|
||||
venueReviewsTitle: 'What guests say',
|
||||
venueReviewsSubtitle: 'Impressions of visitors about the Devodytyji venue.',
|
||||
ctaTitle: 'Come out into the fresh air',
|
||||
ctaText: 'Subscribe to announcements or just drop by — admission is free.',
|
||||
ctaButton: 'Write to us',
|
||||
},
|
||||
events: {
|
||||
pageTitle: 'Events',
|
||||
pageDescription: 'All events at the Devodytyji venue: concerts, movies, talks and markets.',
|
||||
filters: 'Filters',
|
||||
searchPlaceholder: 'Search events…',
|
||||
upcomingTab: 'Upcoming',
|
||||
pastTab: 'Past',
|
||||
allTab: 'All',
|
||||
emptyUpcoming: 'No upcoming events yet — check back later.',
|
||||
emptyPast: 'No past events yet.',
|
||||
emptySearch: 'Nothing matches your search.',
|
||||
pastLabel: 'Past',
|
||||
upcomingLabel: 'Upcoming',
|
||||
},
|
||||
event: {
|
||||
date: 'Date',
|
||||
time: 'Time',
|
||||
venue: 'Venue',
|
||||
statusDraft: 'Draft',
|
||||
statusPublished: 'Published',
|
||||
reviewsTitle: 'Event reviews',
|
||||
reviewCount: 'reviews',
|
||||
noReviews: 'No reviews yet. Be the first!',
|
||||
writeReview: 'Write a review',
|
||||
backToEvents: 'Back to all events',
|
||||
share: 'Share',
|
||||
signInToReview: 'Sign in to leave a review',
|
||||
},
|
||||
about: {
|
||||
pageTitle: 'About the venue',
|
||||
pageDescription: 'The story and atmosphere of the Devodytyji cultural venue.',
|
||||
storyTitle: 'Our story',
|
||||
story:
|
||||
'Devodytyji began as an ordinary yard where neighbors gathered. Over time it became an open cultural venue: music, movies under the stars, talks, fairs and workshops. We believe culture lives best in the open air — among people, under the open sky.',
|
||||
venueReviewsTitle: 'Venue reviews',
|
||||
venueReviewCount: 'reviews',
|
||||
writeVenueReview: 'Leave a review about the venue',
|
||||
noVenueReviews: 'No reviews yet. Be the first!',
|
||||
contactsTitle: 'Contacts',
|
||||
},
|
||||
contacts: {
|
||||
pageTitle: 'Contacts',
|
||||
pageDescription: 'Get in touch with the Devodytyji team or send us a message.',
|
||||
formTitle: 'Write to us',
|
||||
name: 'Name',
|
||||
email: 'Email',
|
||||
message: 'Message',
|
||||
namePlaceholder: 'Your name',
|
||||
emailPlaceholder: 'you@example.com',
|
||||
messagePlaceholder: 'Your message…',
|
||||
send: 'Send',
|
||||
sending: 'Sending…',
|
||||
successTitle: 'Message sent!',
|
||||
successText: 'Thank you, we will get back to you soon.',
|
||||
errorTitle: 'Could not send',
|
||||
errorText: 'Please try again a bit later.',
|
||||
phone: 'Phone',
|
||||
emailLabel: 'Email',
|
||||
addressLabel: 'Address',
|
||||
hoursLabel: 'Opening hours',
|
||||
hoursValue: 'Every day, 10:00 — 23:00',
|
||||
followUs: 'Follow us',
|
||||
},
|
||||
map: {
|
||||
pageTitle: 'How to get there',
|
||||
pageDescription: 'Map and address of the Devodytyji cultural venue.',
|
||||
howToGet: 'How to get there',
|
||||
address: 'Address',
|
||||
hint: 'A tiny map with a single marker — you cannot get lost.',
|
||||
},
|
||||
auth: {
|
||||
loginTitle: 'Sign in',
|
||||
loginSubtitle: 'Good to see you again!',
|
||||
registerTitle: 'Create an account',
|
||||
registerSubtitle: 'Join us to leave reviews.',
|
||||
email: 'Email',
|
||||
password: 'Password',
|
||||
fullName: 'Name',
|
||||
loginButton: 'Sign in',
|
||||
registerButton: 'Sign up',
|
||||
loggingIn: 'Signing in…',
|
||||
registering: 'Signing up…',
|
||||
oauthGoogle: 'Continue with Google',
|
||||
oauthGitHub: 'Continue with GitHub',
|
||||
noAccount: 'No account yet?',
|
||||
hasAccount: 'Already have an account?',
|
||||
loginLink: 'Sign in',
|
||||
registerLink: 'Sign up',
|
||||
orContinueWith: 'or continue with',
|
||||
passwordLength: 'Password must be at least 6 characters',
|
||||
errorInvalid: 'Invalid email or password.',
|
||||
errorEmailExists: 'A user with this email already exists.',
|
||||
errorGeneric: 'Something went wrong. Please try again.',
|
||||
backHome: 'Back home',
|
||||
},
|
||||
reviewForm: {
|
||||
ratingLabel: 'Your rating',
|
||||
commentLabel: 'Comment',
|
||||
commentPlaceholder: 'Share your impressions…',
|
||||
submit: 'Submit review',
|
||||
submitting: 'Submitting…',
|
||||
success: 'Thank you for your review!',
|
||||
error: 'Could not submit the review.',
|
||||
errorNotLoggedIn: 'Please sign in to leave a review.',
|
||||
delete: 'Delete',
|
||||
deleteConfirm: 'Delete this review?',
|
||||
starsHint: 'Choose from 1 to 5 stars',
|
||||
},
|
||||
admin: {
|
||||
dashboardTitle: 'Dashboard',
|
||||
eventsTitle: 'Events',
|
||||
reviewsTitle: 'Reviews',
|
||||
messagesTitle: 'Messages',
|
||||
backToSite: 'Back to site',
|
||||
navDashboard: 'Overview',
|
||||
navEvents: 'Events',
|
||||
navReviews: 'Reviews',
|
||||
navMessages: 'Messages',
|
||||
statsPublished: 'Published events',
|
||||
statsDraft: 'Drafts',
|
||||
statsUnread: 'Unread messages',
|
||||
statsReviews: 'Total reviews',
|
||||
newEvent: 'New event',
|
||||
editEvent: 'Edit event',
|
||||
backToList: 'Back to events list',
|
||||
eventTitleUa: 'Title (UA)',
|
||||
eventTitleEn: 'Title (EN)',
|
||||
eventDescUa: 'Description (UA)',
|
||||
eventDescEn: 'Description (EN)',
|
||||
slug: 'Slug (URL)',
|
||||
startsAt: 'Starts at',
|
||||
endsAt: 'Ends at',
|
||||
venueName: 'Venue name',
|
||||
coverImage: 'Cover image',
|
||||
uploadCover: 'Upload cover',
|
||||
replacingCover: 'Uploaded!',
|
||||
status: 'Status',
|
||||
publish: 'Publish',
|
||||
unpublish: 'Unpublish',
|
||||
save: 'Save',
|
||||
create: 'Create',
|
||||
saving: 'Saving…',
|
||||
saved: 'Saved!',
|
||||
deleteEvent: 'Delete event',
|
||||
deleteEventConfirm: 'Delete this event? This cannot be undone.',
|
||||
deleteReview: 'Delete review',
|
||||
deleteMessage: 'Delete message',
|
||||
emptyEvents: 'No events yet. Create the first one!',
|
||||
emptyReviews: 'No reviews yet.',
|
||||
emptyMessages: 'No messages yet.',
|
||||
markRead: 'Mark as read',
|
||||
markUnread: 'Mark as unread',
|
||||
from: 'From',
|
||||
viewOnSite: 'View on site',
|
||||
notFound: 'Event not found',
|
||||
},
|
||||
footer: {
|
||||
tagline: 'An open-air cultural venue.',
|
||||
rights: 'All rights reserved.',
|
||||
navTitle: 'Navigation',
|
||||
contactTitle: 'Contacts',
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,231 @@
|
||||
export const ua = {
|
||||
meta: {
|
||||
siteName: 'Devodytyji',
|
||||
homeTitle: 'Devodytyji — відкритий культурний майданчик',
|
||||
homeDescription:
|
||||
'Devodytyji — відкритий простір на свіжому повітрі: концерти, кіно, лекції та маркети просто неба.',
|
||||
defaultOgImage: '/og-cover.jpg',
|
||||
},
|
||||
nav: {
|
||||
home: 'Головна',
|
||||
events: 'Події',
|
||||
about: 'Про майданчик',
|
||||
map: 'Як дістатись',
|
||||
contacts: 'Контакти',
|
||||
admin: 'Адмін-панель',
|
||||
login: 'Увійти',
|
||||
register: 'Реєстрація',
|
||||
logout: 'Вийти',
|
||||
},
|
||||
common: {
|
||||
loading: 'Завантаження…',
|
||||
saving: 'Збереження…',
|
||||
save: 'Зберегти',
|
||||
cancel: 'Скасувати',
|
||||
submit: 'Надіслати',
|
||||
close: 'Закрити',
|
||||
search: 'Пошук',
|
||||
all: 'Усі',
|
||||
upcoming: 'Найближчі',
|
||||
past: 'Минулі',
|
||||
published: 'Опубліковано',
|
||||
draft: 'Чернетка',
|
||||
edit: 'Редагувати',
|
||||
delete: 'Видалити',
|
||||
read: 'Прочитано',
|
||||
unread: 'Не прочитано',
|
||||
back: 'Назад',
|
||||
noResults: 'Нічого не знайдено',
|
||||
error: 'Сталася помилка',
|
||||
success: 'Успішно',
|
||||
required: "Обов'язкове поле",
|
||||
optional: 'необов’язково',
|
||||
reviews: 'відгуки',
|
||||
rating: 'Оцінка',
|
||||
writeReview: 'Залишити відгук',
|
||||
view: 'Переглянути',
|
||||
minutes: 'хв',
|
||||
},
|
||||
hero: {
|
||||
badge: 'Просто неба • Завжди відкрито',
|
||||
title: 'Культурний майданчик під відкритим небом',
|
||||
subtitle:
|
||||
'Devodytyji — це простір для концертів, кіно, лекцій і маркетів. Один двір, одна сцена, безліч історій.',
|
||||
ctaEvents: 'Дивитись події',
|
||||
ctaMap: 'Як дістатись',
|
||||
},
|
||||
home: {
|
||||
nextEvents: 'Найближчі події',
|
||||
nextEventsSubtitle: 'Що відбуватиметься на майданчику найближчим часом.',
|
||||
viewAllEvents: 'Усі події',
|
||||
venueReviewsTitle: 'Що кажуть гості',
|
||||
venueReviewsSubtitle: 'Враження відвідувачів про майданчик Devodytyji.',
|
||||
ctaTitle: 'Приходьте на свіже повітря',
|
||||
ctaText: 'Підпишіться на анонси або просто завітайте — вхід вільний.',
|
||||
ctaButton: 'Написати нам',
|
||||
},
|
||||
events: {
|
||||
pageTitle: 'Події',
|
||||
pageDescription: 'Усі події на майданчику Devodytyji: концерти, кіно, лекції та маркети.',
|
||||
filters: 'Фільтри',
|
||||
searchPlaceholder: 'Шукати події…',
|
||||
upcomingTab: 'Найближчі',
|
||||
pastTab: 'Минулі',
|
||||
allTab: 'Усі',
|
||||
emptyUpcoming: 'Найближчих подій поки немає — зазирніть пізніше.',
|
||||
emptyPast: 'Минулих подій поки немає.',
|
||||
emptySearch: 'За вашим запитом нічого не знайдено.',
|
||||
pastLabel: 'Минула',
|
||||
upcomingLabel: 'Найближча',
|
||||
},
|
||||
event: {
|
||||
date: 'Дата',
|
||||
time: 'Час',
|
||||
venue: 'Локація',
|
||||
statusDraft: 'Чернетка',
|
||||
statusPublished: 'Опубліковано',
|
||||
reviewsTitle: 'Відгуки про подію',
|
||||
reviewCount: 'відгуків',
|
||||
noReviews: 'Відгуків поки немає. Будьте першим!',
|
||||
writeReview: 'Залишити відгук',
|
||||
backToEvents: 'До всіх подій',
|
||||
share: 'Поділитись',
|
||||
signInToReview: 'Увійдіть, щоб залишити відгук',
|
||||
},
|
||||
about: {
|
||||
pageTitle: 'Про майданчик',
|
||||
pageDescription: 'Історія та атмосфера культурного майданчика Devodytyji.',
|
||||
storyTitle: 'Наша історія',
|
||||
story:
|
||||
'Devodytyji починався як звичайний двір, де збиралися сусіди. З часом він перетворився на відкритий культурний майданчик: тут грають музику, показують кіно під зорями, проводять лекції, ярмарки та майстер-класи. Ми віримо, що культура найкраще живе просто неба — під відкритим небом, у колі людей.',
|
||||
venueReviewsTitle: 'Відгуки про майданчик',
|
||||
venueReviewCount: 'відгуків',
|
||||
writeVenueReview: 'Залишити відгук про майданчик',
|
||||
noVenueReviews: 'Відгуків поки немає. Будьте першим!',
|
||||
contactsTitle: 'Контакти',
|
||||
},
|
||||
contacts: {
|
||||
pageTitle: 'Контакти',
|
||||
pageDescription: 'Зв’яжіться з командою Devodytyji або напишіть нам повідомлення.',
|
||||
formTitle: 'Напишіть нам',
|
||||
name: "Ім'я",
|
||||
email: 'Email',
|
||||
message: 'Повідомлення',
|
||||
namePlaceholder: "Ваше ім'я",
|
||||
emailPlaceholder: 'you@example.com',
|
||||
messagePlaceholder: 'Ваше повідомлення…',
|
||||
send: 'Надіслати',
|
||||
sending: 'Надсилання…',
|
||||
successTitle: 'Повідомлення надіслано!',
|
||||
successText: 'Дякуємо, ми зв’яжемося з вами найближчим часом.',
|
||||
errorTitle: 'Не вдалося надіслати',
|
||||
errorText: 'Спробуйте ще раз трохи згодом.',
|
||||
phone: 'Телефон',
|
||||
emailLabel: 'Email',
|
||||
addressLabel: 'Адреса',
|
||||
hoursLabel: 'Години роботи',
|
||||
hoursValue: 'Щодня з 10:00 до 23:00',
|
||||
followUs: 'Стежте за нами',
|
||||
},
|
||||
map: {
|
||||
pageTitle: 'Як дістатись',
|
||||
pageDescription: 'Карта та адреса культурного майданчика Devodytyji.',
|
||||
howToGet: 'Як дістатись',
|
||||
address: 'Адреса',
|
||||
hint: 'Маленька карта з одним маркером — ви точно не заблукаєте.',
|
||||
},
|
||||
auth: {
|
||||
loginTitle: 'Увійти',
|
||||
loginSubtitle: 'Раді бачити вас знову!',
|
||||
registerTitle: 'Створити акаунт',
|
||||
registerSubtitle: 'Приєднуйтесь, щоб залишати відгуки.',
|
||||
email: 'Email',
|
||||
password: 'Пароль',
|
||||
fullName: "Ім'я",
|
||||
loginButton: 'Увійти',
|
||||
registerButton: 'Зареєструватися',
|
||||
loggingIn: 'Вхід…',
|
||||
registering: 'Реєстрація…',
|
||||
oauthGoogle: 'Увійти через Google',
|
||||
oauthGitHub: 'Увійти через GitHub',
|
||||
noAccount: 'Немає акаунта?',
|
||||
hasAccount: 'Вже маєте акаунт?',
|
||||
loginLink: 'Увійти',
|
||||
registerLink: 'Зареєструватися',
|
||||
orContinueWith: 'або продовжити через',
|
||||
passwordLength: 'Пароль має містити щонайменше 6 символів',
|
||||
errorInvalid: 'Неправильний email або пароль.',
|
||||
errorEmailExists: 'Користувач із таким email уже існує.',
|
||||
errorGeneric: 'Щось пішло не так. Спробуйте ще раз.',
|
||||
backHome: 'На головну',
|
||||
},
|
||||
reviewForm: {
|
||||
ratingLabel: 'Ваша оцінка',
|
||||
commentLabel: 'Коментар',
|
||||
commentPlaceholder: 'Поділіться враженнями…',
|
||||
submit: 'Надіслати відгук',
|
||||
submitting: 'Надсилання…',
|
||||
success: 'Дякуємо за відгук!',
|
||||
error: 'Не вдалося надіслати відгук.',
|
||||
errorNotLoggedIn: 'Увійдіть, щоб залишити відгук.',
|
||||
delete: 'Видалити',
|
||||
deleteConfirm: 'Видалити цей відгук?',
|
||||
starsHint: 'Оберіть від 1 до 5 зірок',
|
||||
},
|
||||
admin: {
|
||||
dashboardTitle: 'Панель керування',
|
||||
eventsTitle: 'Події',
|
||||
reviewsTitle: 'Відгуки',
|
||||
messagesTitle: 'Повідомлення',
|
||||
backToSite: 'На сайт',
|
||||
navDashboard: 'Огляд',
|
||||
navEvents: 'Події',
|
||||
navReviews: 'Відгуки',
|
||||
navMessages: 'Повідомлення',
|
||||
statsPublished: 'Опубліковано подій',
|
||||
statsDraft: 'Чернеток',
|
||||
statsUnread: 'Непрочитаних повідомлень',
|
||||
statsReviews: 'Всього відгуків',
|
||||
newEvent: 'Нова подія',
|
||||
editEvent: 'Редагувати подію',
|
||||
backToList: 'До списку подій',
|
||||
eventTitleUa: 'Назва (укр.)',
|
||||
eventTitleEn: 'Назва (англ.)',
|
||||
eventDescUa: 'Опис (укр.)',
|
||||
eventDescEn: 'Опис (англ.)',
|
||||
slug: 'Слаг (URL)',
|
||||
startsAt: 'Початок',
|
||||
endsAt: 'Кінець',
|
||||
venueName: 'Назва локації',
|
||||
coverImage: 'Обкладинка',
|
||||
uploadCover: 'Завантажити обкладинку',
|
||||
replacingCover: 'Завантажено!',
|
||||
status: 'Статус',
|
||||
publish: 'Опублікувати',
|
||||
unpublish: 'Зняти з публікації',
|
||||
save: 'Зберегти',
|
||||
create: 'Створити',
|
||||
saving: 'Збереження…',
|
||||
saved: 'Збережено!',
|
||||
deleteEvent: 'Видалити подію',
|
||||
deleteEventConfirm: 'Видалити цю подію? Цю дію не можна скасувати.',
|
||||
deleteReview: 'Видалити відгук',
|
||||
deleteMessage: 'Видалити повідомлення',
|
||||
emptyEvents: 'Подій поки немає. Створіть першу!',
|
||||
emptyReviews: 'Відгуків поки немає.',
|
||||
emptyMessages: 'Повідомлень поки немає.',
|
||||
markRead: 'Позначити прочитаним',
|
||||
markUnread: 'Позначити непрочитаним',
|
||||
from: 'Від',
|
||||
viewOnSite: 'Переглянути на сайті',
|
||||
notFound: 'Подію не знайдено',
|
||||
},
|
||||
footer: {
|
||||
tagline: 'Відкритий культурний майданчик просто неба.',
|
||||
rights: 'Всі права захищено.',
|
||||
navTitle: 'Навігація',
|
||||
contactTitle: 'Контакти',
|
||||
},
|
||||
};
|
||||
|
||||
export type Dict = typeof ua;
|
||||
@@ -0,0 +1,57 @@
|
||||
import type { Locale } from './i18n';
|
||||
|
||||
export function intlLocale(locale: Locale): string {
|
||||
return locale === 'ua' ? 'uk-UA' : 'en-GB';
|
||||
}
|
||||
|
||||
export function formatDate(locale: Locale, iso: string): string {
|
||||
return new Intl.DateTimeFormat(intlLocale(locale), {
|
||||
day: 'numeric',
|
||||
month: 'long',
|
||||
year: 'numeric',
|
||||
}).format(new Date(iso));
|
||||
}
|
||||
|
||||
export function formatTime(locale: Locale, iso: string): string {
|
||||
return new Intl.DateTimeFormat(intlLocale(locale), {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
}).format(new Date(iso));
|
||||
}
|
||||
|
||||
export function formatDateTime(locale: Locale, iso: string): string {
|
||||
return new Intl.DateTimeFormat(intlLocale(locale), {
|
||||
day: 'numeric',
|
||||
month: 'short',
|
||||
year: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
}).format(new Date(iso));
|
||||
}
|
||||
|
||||
export function formatShortDate(locale: Locale, iso: string): string {
|
||||
return new Intl.DateTimeFormat(intlLocale(locale), {
|
||||
day: 'numeric',
|
||||
month: 'short',
|
||||
}).format(new Date(iso));
|
||||
}
|
||||
|
||||
export function formatRelative(locale: Locale, iso: string): string {
|
||||
return new Intl.DateTimeFormat(intlLocale(locale), {
|
||||
day: 'numeric',
|
||||
month: 'short',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
}).format(new Date(iso));
|
||||
}
|
||||
|
||||
export function initials(name: string | null | undefined): string {
|
||||
if (!name) return '?';
|
||||
return name
|
||||
.split(/\s+/)
|
||||
.filter(Boolean)
|
||||
.slice(0, 2)
|
||||
.map((part) => part[0])
|
||||
.join('')
|
||||
.toUpperCase();
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import { ua, type Dict } from './dictionaries/ua';
|
||||
import { en } from './dictionaries/en';
|
||||
|
||||
export type { Dict } from './dictionaries/ua';
|
||||
export type Locale = 'ua' | 'en';
|
||||
export const LOCALES: Locale[] = ['ua', 'en'];
|
||||
export const DEFAULT_LOCALE: Locale = 'ua';
|
||||
|
||||
const dictionaries: Record<Locale, Dict> = { ua, en };
|
||||
|
||||
export function getDict(locale: string | undefined): Dict {
|
||||
return dictionaries[(locale as Locale) ?? DEFAULT_LOCALE] ?? ua;
|
||||
}
|
||||
|
||||
export function isLocale(value: string | undefined): value is Locale {
|
||||
return value === 'ua' || value === 'en';
|
||||
}
|
||||
|
||||
export function getLocaleFromUrl(url: URL): Locale {
|
||||
const first = url.pathname.split('/')[1];
|
||||
return isLocale(first) ? first : DEFAULT_LOCALE;
|
||||
}
|
||||
|
||||
export function localizedPath(locale: Locale, path: string): string {
|
||||
const clean = path.startsWith('/') ? path : `/${path}`;
|
||||
return `/${locale}${clean}`;
|
||||
}
|
||||
|
||||
export function getAlternateLocales(current: Locale): Locale[] {
|
||||
return LOCALES.filter((l) => l !== current);
|
||||
}
|
||||
|
||||
export function stripLocaleFromPath(locale: Locale, pathname: string): string {
|
||||
if (pathname === `/${locale}` || pathname === `/${locale}/`) return '/';
|
||||
if (pathname.startsWith(`/${locale}/`)) return pathname.slice(`/${locale}`.length);
|
||||
return pathname;
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
export async function readBody<T extends Record<string, unknown>>(request: Request): Promise<T> {
|
||||
const contentType = request.headers.get('content-type') ?? '';
|
||||
if (contentType.includes('application/json')) {
|
||||
return (await request.json()) as T;
|
||||
}
|
||||
if (contentType.includes('multipart/form-data')) {
|
||||
const form = await request.formData();
|
||||
const out: Record<string, unknown> = {};
|
||||
form.forEach((value, key) => {
|
||||
out[key] = value;
|
||||
});
|
||||
return out as T;
|
||||
}
|
||||
const urlEncoded = await request.text();
|
||||
const params = new URLSearchParams(urlEncoded);
|
||||
const out: Record<string, unknown> = {};
|
||||
for (const [key, value] of params.entries()) {
|
||||
out[key] = value;
|
||||
}
|
||||
return out as T;
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import { createBrowserClient, createServerClient } from '@supabase/ssr';
|
||||
import type { AstroCookies } from 'astro';
|
||||
import type { SupabaseClient } from '@supabase/supabase-js';
|
||||
|
||||
interface ServerContext {
|
||||
cookies: AstroCookies;
|
||||
request: Request;
|
||||
}
|
||||
|
||||
export function getSupabaseUrl(): string {
|
||||
const url = import.meta.env.PUBLIC_SUPABASE_URL;
|
||||
if (!url) throw new Error('PUBLIC_SUPABASE_URL is not set');
|
||||
return url;
|
||||
}
|
||||
|
||||
export function getSupabaseAnonKey(): string {
|
||||
const key = import.meta.env.PUBLIC_SUPABASE_ANON_KEY;
|
||||
if (!key) throw new Error('PUBLIC_SUPABASE_ANON_KEY is not set');
|
||||
return key;
|
||||
}
|
||||
|
||||
function parseCookieHeader(cookieHeader: string): { name: string; value: string }[] {
|
||||
if (!cookieHeader) return [];
|
||||
const out: { name: string; value: string }[] = [];
|
||||
for (const pair of cookieHeader.split(';')) {
|
||||
const eq = pair.indexOf('=');
|
||||
if (eq === -1) continue;
|
||||
const name = pair.slice(0, eq).trim();
|
||||
if (!name) continue;
|
||||
let value = pair.slice(eq + 1).trim();
|
||||
try {
|
||||
value = decodeURIComponent(value);
|
||||
} catch {
|
||||
// keep raw value
|
||||
}
|
||||
out.push({ name, value });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export function createServerSupabase(context: ServerContext): SupabaseClient {
|
||||
return createServerClient(getSupabaseUrl(), getSupabaseAnonKey(), {
|
||||
cookies: {
|
||||
getAll() {
|
||||
return parseCookieHeader(context.request.headers.get('cookie') ?? '');
|
||||
},
|
||||
setAll(cookiesToSet) {
|
||||
for (const { name, value, options } of cookiesToSet) {
|
||||
context.cookies.set(name, value, options);
|
||||
}
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function createBrowserSupabase(): SupabaseClient {
|
||||
return createBrowserClient(getSupabaseUrl(), getSupabaseAnonKey());
|
||||
}
|
||||
|
||||
export function getSiteUrl(): string {
|
||||
return import.meta.env.PUBLIC_SITE_URL ?? 'http://localhost:4321';
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import type { Locale } from './i18n';
|
||||
|
||||
export interface VenueInfo {
|
||||
name: string;
|
||||
lat: number;
|
||||
lng: number;
|
||||
phone: string;
|
||||
email: string;
|
||||
hours: string;
|
||||
}
|
||||
|
||||
export const VENUE: VenueInfo = {
|
||||
name: 'Devodytyji',
|
||||
lat: 50.4584,
|
||||
lng: 30.5199,
|
||||
phone: '+380 44 000 00 00',
|
||||
email: 'hello@devodytyji.example',
|
||||
hours: '10:00 – 23:00',
|
||||
};
|
||||
|
||||
export function venueAddress(locale: Locale): string {
|
||||
return locale === 'en' ? '12 Vifleemska St, Kyiv, Ukraine' : 'вул. Вифлеємська, 12, Київ, Україна';
|
||||
}
|
||||
Reference in New Issue
Block a user